diff --git a/404.html b/404.html index 4206597..2ca5fd4 100644 --- a/404.html +++ b/404.html @@ -9,7 +9,7 @@ - + Page not found | SyberLabs -
SyberLabs
diff --git a/scripts/qa.mjs b/scripts/qa.mjs index 46d6ea7..a7a7958 100644 --- a/scripts/qa.mjs +++ b/scripts/qa.mjs @@ -12,13 +12,12 @@ // - sitemap.xml URLs with no page, and pages (except 404.html and the kit demo) missing from the sitemap // - files the CI workflow tests for (`test -s dist/...`) that are missing // Exits non-zero on broken internal links or missing files; everything else is a warning. -import { readFile, readdir, stat } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { existsSync, statSync } from 'node:fs'; import { resolve, join, relative, posix } from 'node:path'; const root = resolve(process.argv[2] || 'dist'); const ORIGIN = 'https://syberlabs.io'; -const SITEMAP_EXEMPT = new Set(['404.html', 'kit/v2/demo.html']); async function walk(dir) { const out = []; @@ -175,7 +174,7 @@ if (existsSync(sitemapFile)) { const locs = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map(m => m[1].trim()); for (const loc of locs) if (!pageUrls.has(loc)) report('sitemap.xml', `sitemap URL has no page: ${loc}`, true); for (const [rel] of parsed) { - if (SITEMAP_EXEMPT.has(rel) || !rel.endsWith('index.html')) continue; + if (!rel.endsWith('index.html')) continue; const url = `${ORIGIN}/${rel.slice(0, -'index.html'.length)}`; if (!locs.includes(url)) report('sitemap.xml', `page missing from sitemap: ${url}`); } diff --git a/server/CONTRACT.md b/server/CONTRACT.md index a65fbaa..1ad8ae9 100644 --- a/server/CONTRACT.md +++ b/server/CONTRACT.md @@ -53,7 +53,7 @@ dependencies: Web Crypto, `fetch`, `URL` and D1 `prepare/bind/first/all/run/batc R1-14; cached; rejects unless 32 bytes), `sealJson(key, value, aad)` / `openJson(key, sealed, aad)` -> value|null (AES-GCM, random 96-bit IV, `b64url(iv||ct)`; open never throws). **http.js** (foundation): `esc(v)` (& < > " ', null -> ''), `CSP`, `SECURITY_HEADERS`, `secure(res)` (copy with -every header set and `Access-Control-Allow-Origin` removed), `html(body, init?)`, `text(body, init?)`, +every header set and `Access-Control-Allow-Origin` removed), `html(body, init?)`, `redirect(location, init?)` (303 default, empty body); `init = {status, headers, cookies: string[]}`. `ERRORS` (`[status, message]` per code), `class HttpError(code, message?)` (`.status` from `ERRORS`), `class AuthError(code, detail?)` (codes: cancelled, expired, provider, disabled). `cookie(name, value, maxAgeSeconds)` (`Path=/; Secure; HttpOnly; SameSite=Lax`, never Domain), @@ -196,8 +196,7 @@ and `helpers.js` (`makeEnv`, `seedRole`, `seedUser`, `seedSession`, `signIn`, `c the no-keys empty state says "Ask an admin" (no names in the repo). - `changes.js` hard-deletes (RFC 5.1; the audit row keeps the old row) and writes only the RFC 8.1 columns, so it works on either migration; `deleted_at`, `created_by`, `updated_*` stay unused. Rows use `staff-change__*` (RFC 2.6), not - `.home-latest*`. It also exports `validateEntry(input)` -> `{entry, errors}`, shared with `scripts/changes-import.mjs` - (`` or `--file `; first line `-- source , N rows`; one bad entry exits 1 with no SQL). + `.home-latest*`. It also exports `validateEntry(input)` -> `{entry, errors}`. - `staff.css` sits at the repo root and is linked as `/staff.css?v=1`: **`scripts/assemble-dist.sh` must copy it into `dist/`** (owner of that file). It aliases `sy-btn--primary` and `sy-h3` (not kit classes) for people/roles. - `privacy/index.html` copy describes what this build does (GitHub and Google, no-scope GitHub, user agent and IP @@ -290,7 +289,7 @@ and `helpers.js` (`makeEnv`, `seedRole`, `seedUser`, `seedSession`, `signIn`, `c takes `errorHtml` for the Add person lookup message, which carries a link. - `projects/latest.js`, the homepage `Latest()` section and its `.home-latest*` CSS are deleted; `scripts/assemble-dist.sh` no longer removes `dist/projects/latest.js`, so the CI `test ! -e` guard can fail, and - CI greps `dist/` for the canary title. `scripts/changes-import.mjs ` still reads the file from git history. + CI greps `dist/` for the canary title. ## Deviations from RFC diff --git a/server/account-api.js b/server/account-api.js index fadda24..48f558f 100644 --- a/server/account-api.js +++ b/server/account-api.js @@ -3,7 +3,7 @@ import { loadSession } from './session.js'; import { newId } from './crypto.js'; import { identityName } from './authz.js'; -export const ACCOUNT_BASE = '/admin/api/v1'; +const ACCOUNT_BASE = '/admin/api/v1'; export const APP_ORIGINS = { omni: 'https://omni.syberlabs.io', rise: 'https://rise.syberlabs.io', sketch: 'https://sketch.syberlabs.io' }; export const isAccountApi = pathname => pathname === ACCOUNT_BASE || pathname.startsWith(ACCOUNT_BASE + '/'); const MAX_BYTES = 1048576; diff --git a/server/http.js b/server/http.js index d442e47..f937ca1 100644 --- a/server/http.js +++ b/server/http.js @@ -8,7 +8,7 @@ export const esc = value => (value == null ? '' : String(value)).replace(/[&<>"' // No script-src or connect-src: default-src 'none' blocks every script on staff pages (RFC-0002 8.2). // Browsers apply form-action to the 303 after POST /auth/start, so each enabled provider is named; Google only // while it is on (oauth.js enabledProviders), so with it off no response mentions it. -export const cspFor = ({ google = false } = {}) => [ +const cspFor = ({ google = false } = {}) => [ "default-src 'none'", // 'unsafe-inline' is for styles only: the shared footer writes style="--sy-accent:…". No remote style and // no font source: staff pages render in the kit's fallback stacks (RFC-0002 2.6). @@ -55,7 +55,6 @@ function build(body, { status = 200, headers = {}, cookies = [] } = {}, type) { } export const html = (body, init) => build(body, init, 'text/html; charset=utf-8'); -export const text = (body, init) => build(body, init, 'text/plain; charset=utf-8'); // Empty-body redirect, 303 unless told otherwise. export function redirect(location, { status = 303, headers = {}, cookies = [] } = {}) { diff --git a/server/oauth.js b/server/oauth.js index 94513e8..1a90037 100644 --- a/server/oauth.js +++ b/server/oauth.js @@ -10,7 +10,7 @@ import * as github from './github.js'; import * as google from './google.js'; import { enabledProviders, googleEnabled } from './providers.js'; -export { PROVIDERS, enabledProviders, googleEnabled } from './providers.js'; +export { googleEnabled } from './providers.js'; export const OAUTH_COOKIE = '__Host-sl_oauth'; export const STATE_TTL_MS = 600000; // GitHub's code lifetime is 10 minutes diff --git a/server/providers.js b/server/providers.js index 78ae436..84ec0e5 100644 --- a/server/providers.js +++ b/server/providers.js @@ -1,7 +1,7 @@ // Which sign-in providers are on. GitHub always; Google only when both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET // are set, and otherwise nothing Google renders and its routes are 404 (RFC-0002 2.1, Packet 5). No imports, so // views and oauth.js can both read it without an import cycle (oauth.js re-exports it). -export const PROVIDERS = ['github', 'google']; +const PROVIDERS = ['github', 'google']; export const enabledProviders = env => PROVIDERS.filter(p => p === 'github' || Boolean(env && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)); diff --git a/server/test/account-api.test.js b/server/test/account-api.test.js index 43b95a8..0b74ddf 100644 --- a/server/test/account-api.test.js +++ b/server/test/account-api.test.js @@ -15,12 +15,12 @@ async function signIn(...args) { return user; } const snapshot = (overrides = {}) => ({ app: 'omni', name: 'Private workspace', payload: { documents: ['hello'] }, requestId: randomUUID(), ...overrides }); -function request(env, path, { method = 'GET', cookie, origin, body, headers = {}, now, expectedUserId = accountForCookie.get(cookie) } = {}) { +function request(env, path, { method = 'GET', cookie, origin, body, headers = {}, expectedUserId = accountForCookie.get(cookie) } = {}) { const h = new Headers(headers); if (cookie) h.set('Cookie', cookie); if (path.startsWith(`${ROOT}/saves`) && expectedUserId) h.set('X-SyberLabs-Expected-User', expectedUserId); if (origin !== undefined) h.set('Origin', origin); - return handle(new Request(new URL(path, ORIGIN), { method, headers: h, body }), env, now); + return handle(new Request(new URL(path, ORIGIN), { method, headers: h, body }), env); } function save(env, cookie, data = snapshot(), opts = {}) { return request(env, `${ROOT}/saves`, { method: 'POST', cookie, origin: OMNI, diff --git a/server/test/board.test.js b/server/test/board.test.js index dd2bd7d..cff6253 100644 --- a/server/test/board.test.js +++ b/server/test/board.test.js @@ -34,17 +34,17 @@ function production(req) { } // GET /admin/board with fetch stubbed; returns the response, its text and every request the checks made. -async function render(env, cookie, { fetch = production, now = NOW } = {}) { +async function render(env, cookie, { fetch = production } = {}) { const stub = stubFetch(fetch); try { - const res = await call(env, '/admin/board', { cookie, now }); + const res = await call(env, '/admin/board', { cookie, now: NOW }); return { res, body: await res.text(), calls: stub.calls }; } finally { stub.restore(); } } -const adminEnv = async (overrides = {}) => { +const adminEnv = async overrides => { const env = makeEnv(overrides); const admin = await signIn(env.DB, { roles: ['role_admin'], now: NOW }); return { env, cookie: admin.cookie }; diff --git a/server/test/d1-shim.js b/server/test/d1-shim.js index e1d2cbd..31094a6 100644 --- a/server/test/d1-shim.js +++ b/server/test/d1-shim.js @@ -1,5 +1,5 @@ // A D1 binding over node:sqlite, so tests run the real migrations/*.sql. Only the surface server/ may -// use: prepare(sql).bind(...).first(col?) / all() / run() / raw(), batch([...]) and exec(sql). +// use: prepare(sql).bind(...).first(col?) / all() / run() and batch([...]). // Like D1: every call is async, binding undefined throws, booleans bind as 1/0, batch is one transaction. import { DatabaseSync } from 'node:sqlite'; import { readFileSync, readdirSync } from 'node:fs'; @@ -52,12 +52,6 @@ class Statement { async all() { return this.exec(); } async run() { return this.exec(); } - - async raw() { - const stmt = this.db.sqlite.prepare(this.sql); - stmt.setReturnArrays(true); - return stmt.all(...this.params); - } } export class D1Shim { @@ -79,11 +73,6 @@ export class D1Shim { } } - async exec(sql) { - this.sqlite.exec(sql); - return { count: 1, duration: 0 }; - } - // Test helpers (not part of D1): rows written so far, including trigger and cascade changes (and rows // a rolled-back batch wrote, so take a fresh baseline after a failure). totalChanges() { @@ -124,6 +113,6 @@ export function freshDb({ upTo = MIGRATION_FILES.length } = {}) { // A binding whose every query rejects, for the "D1 throw -> 503" tests. export function brokenDb() { const fail = async () => { throw new Error('D1_ERROR: simulated outage'); }; - const stmt = { bind: () => stmt, first: fail, all: fail, run: fail, raw: fail }; - return { prepare: () => stmt, batch: fail, exec: fail }; + const stmt = { bind: () => stmt, first: fail, all: fail, run: fail }; + return { prepare: () => stmt, batch: fail }; } diff --git a/server/test/google.test.js b/server/test/google.test.js index 6f76c71..3f3ad5b 100644 --- a/server/test/google.test.js +++ b/server/test/google.test.js @@ -15,9 +15,9 @@ const claims = (changes = {}) => ({ iss: 'https://accounts.google.com', aud: 'go sub: 'google-sub-test', nonce: 'nonce-test', exp: NOW / 1000 + 3600, iat: NOW / 1000, email: 'display@example.test', email_verified: true, ...changes }); const token = c => `${b64urlEncode(JSON.stringify({ alg: 'RS256' }))}.${b64urlEncode(JSON.stringify(c))}.signature`; -function ctx(env, path, { cookie = '', form = {}, user = null, now = NOW } = {}) { +function ctx(env, path, { cookie = '', form = {}, user = null } = {}) { const url = new URL(path, ORIGIN); - return { env, url, now, user, params: { provider: 'google' }, form: new URLSearchParams(form), + return { env, url, now: NOW, user, params: { provider: 'google' }, form: new URLSearchParams(form), request: new Request(url, { headers: cookie ? { Cookie: cookie } : {} }) }; } const cookieValue = res => res.headers.getSetCookie().find(c => c.startsWith(`${OAUTH_COOKIE}=`)).split(';')[0]; @@ -26,15 +26,15 @@ async function start(env, opts = {}) { const url = new URL(res.headers.get('Location')); return { res, url, state: url.searchParams.get('state'), nonce: url.searchParams.get('nonce'), cookie: cookieValue(res) }; } -async function add(env, u, sessionToken, opts = {}) { +async function add(env, sessionToken) { const cookie = `${SESSION_COOKIE}=${sessionToken}`; const session = await loadSession(env, ctx(env, '/admin/account', { cookie }).request, NOW); - const res = await addGoogle(ctx(env, '/admin/account/add-google', { cookie, user: session.user, ...opts })); + const res = await addGoogle(ctx(env, '/admin/account/add-google', { cookie, user: session.user })); const url = new URL(res.headers.get('Location'), ORIGIN); return { res, url, state: url.searchParams.get('state'), nonce: url.searchParams.get('nonce'), cookie: `${cookie}; ${cookieValue(res)}` }; } -async function finish(env, flow, changes = {}, handler) { - const stub = stubFetch(req => handler ? handler(req) : { id_token: token(claims({ nonce: flow.nonce, ...changes })) }); +async function finish(env, flow) { + const stub = stubFetch(() => ({ id_token: token(claims({ nonce: flow.nonce })) })); try { const res = await callback(ctx(env, `/auth/callback/google?code=google-code&state=${flow.state}`, { cookie: flow.cookie })); return { res, calls: stub.calls }; @@ -153,7 +153,7 @@ test('Add Google seals the signed-in user and uses chooser; callback inserts met const env = envOf(); const u = seedUser(env.DB); const sessionToken = await seedSession(env.DB, { ...u, now: NOW }); - const flow = await add(env, u, sessionToken); + const flow = await add(env, sessionToken); assert.equal(flow.url.searchParams.get('prompt'), 'select_account'); const st = await openState(env, 'google', flow.cookie.split(`${OAUTH_COOKIE}=`)[1], NOW); assert.equal(st.l, u.userId); @@ -169,7 +169,7 @@ test('Add Google rejects ended sessions, switched users, and a subject already b const env = envOf(); const u = seedUser(env.DB); const sessionToken = await seedSession(env.DB, { ...u, now: NOW }); - const flow = await add(env, u, sessionToken); + const flow = await add(env, sessionToken); if (reason === 'ended') env.DB.sqlite.prepare('DELETE FROM sessions').run(); if (reason === 'switched') { const other = seedUser(env.DB); @@ -189,7 +189,7 @@ test('Add Google transaction rechecks session revocation and concurrent Google a const env = envOf(); const u = seedUser(env.DB); const sessionToken = await seedSession(env.DB, { ...u, now: NOW }); - const flow = await add(env, u, sessionToken); + const flow = await add(env, sessionToken); const batch = env.DB.batch.bind(env.DB); env.DB.batch = async stmts => { if (race === 'revoked') env.DB.sqlite.prepare('DELETE FROM sessions WHERE id_hash = ?').run(await sha256Hex(sessionToken)); @@ -212,7 +212,7 @@ test('two concurrent Google callbacks for one person attach only one method and const env = envOf(); const u = seedUser(env.DB); const sessionToken = await seedSession(env.DB, { ...u, now: NOW }); - const flows = [await add(env, u, sessionToken), await add(env, u, sessionToken)]; + const flows = [await add(env, sessionToken), await add(env, sessionToken)]; let release; const ready = new Promise(resolve => { release = resolve; }); const batch = env.DB.batch.bind(env.DB); @@ -243,7 +243,7 @@ test('two people concurrently claiming one Google subject produce one link and o const flows = []; for (const u of users) { const sessionToken = await seedSession(env.DB, { ...u, now: NOW }); - flows.push(await add(env, u, sessionToken)); + flows.push(await add(env, sessionToken)); } let release; const ready = new Promise(resolve => { release = resolve; }); @@ -272,7 +272,7 @@ test('signout while Google callback awaits its transaction prevents linking and const env = envOf(); const u = seedUser(env.DB); const sessionToken = await seedSession(env.DB, { ...u, now: NOW }); - const flow = await add(env, u, sessionToken); + const flow = await add(env, sessionToken); let atBatch; const entered = new Promise(resolve => { atBatch = resolve; }); let resume; diff --git a/server/test/helpers.js b/server/test/helpers.js index 0c069b9..f9c18eb 100644 --- a/server/test/helpers.js +++ b/server/test/helpers.js @@ -6,7 +6,6 @@ import { newId, randomToken, sha256Hex } from '../crypto.js'; export const ORIGIN = 'https://syberlabs.io'; export const SESSION_COOKIE = '__Host-sl_session'; -export const OAUTH_COOKIE = '__Host-sl_oauth'; export const HOUR = 3600 * 1000; export function makeEnv(overrides = {}) { diff --git a/server/test/migration.test.js b/server/test/migration.test.js index 15b6cbf..d38b645 100644 --- a/server/test/migration.test.js +++ b/server/test/migration.test.js @@ -275,7 +275,6 @@ test('d1 shim: D1 result shapes, binding rules, atomic batch, broken binding', a { results: [{ id: 'u1', display_name: 'A' }], success: true, meta: { changes: 0, last_row_id: 0, changed_db: false, rows_read: 1 } }); assert.equal(await db.prepare('SELECT display_name FROM users WHERE id = ?').bind('u1').first('display_name'), 'A'); assert.equal(await db.prepare('SELECT * FROM users WHERE id = ?').bind('none').first(), null); - assert.deepEqual(await db.prepare('SELECT id FROM users').raw(), [['u1']]); assert.equal(await db.prepare('SELECT ?1 AS t, ?1 + ?2 AS s').bind(true, 2).first('s'), 3); assert.throws(() => db.prepare('SELECT ?').bind(undefined), /D1_TYPE_ERROR/); const ret = await db.prepare("UPDATE users SET display_name = 'B' WHERE id = 'u1' RETURNING id").all(); diff --git a/server/test/oauth.test.js b/server/test/oauth.test.js index 39e2d3c..1d4b89a 100644 --- a/server/test/oauth.test.js +++ b/server/test/oauth.test.js @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeEnv, seedUser, seedSession, stubFetch, ORIGIN, HOUR } from './helpers.js'; -import { b64urlDecode, b64urlEncode, stateKey, openJson, randomToken, sha256Hex, newId } from '../crypto.js'; +import { b64urlDecode, b64urlEncode, stateKey, openJson, randomToken, sha256Hex } from '../crypto.js'; import { HttpError } from '../http.js'; import { OAUTH_COOKIE, STATE_TTL_MS, sealState, openState, startLogin, callback } from '../oauth.js'; @@ -34,8 +34,8 @@ function assertHostCookie(setCookie, name, maxAge) { assert.ok(!/domain/i.test(setCookie)); } -async function start(env, form = {}, { provider = 'github', now = NOW } = {}) { - const res = await startLogin(ctxOf(env, { method: 'POST', path: `/auth/start/${provider}`, form, provider, now })); +async function start(env, form = {}, { now = NOW } = {}) { + const res = await startLogin(ctxOf(env, { method: 'POST', path: '/auth/start/github', form, now })); const loc = new URL(res.headers.get('Location')); const sealed = cookieValue(res, OAUTH_COOKIE); return { res, loc, sealed, state: loc.searchParams.get('state'), cookie: `${OAUTH_COOKIE}=${sealed}` }; diff --git a/server/test/people.test.js b/server/test/people.test.js index f3e1a31..61de433 100644 --- a/server/test/people.test.js +++ b/server/test/people.test.js @@ -2,7 +2,6 @@ // and Add person (20), which replaced invites (RFC-0002 R3-4). import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { sha256Hex } from '../crypto.js'; import { call, makeEnv, seedSession, signIn, stubFetch } from './helpers.js'; const q = (db, sql, ...a) => db.sqlite.prepare(sql).get(...a); @@ -327,8 +326,8 @@ test('add person end to end: the added id signs in through case a after a rename const admin = await signIn(env.DB, { roles: ['role_admin'] }); await add(env, admin.cookie, { role: 'role_viewer', account: '9191', confirm: '1' }); const { startLogin, callback, OAUTH_COOKIE } = await import('../oauth.js'); - const ctx = (path, extra) => ({ env, url: new URL(path, env.ORIGIN), now: Date.now(), params: { provider: 'github' }, - form: new URLSearchParams(), user: null, perms: new Set(), ...extra }); + const ctx = path => ({ env, url: new URL(path, env.ORIGIN), now: Date.now(), params: { provider: 'github' }, + form: new URLSearchParams(), user: null, perms: new Set() }); const started = await startLogin({ ...ctx('/auth/start/github'), request: new Request(`${env.ORIGIN}/auth/start/github`, { method: 'POST' }) }); const sealed = started.headers.getSetCookie()[0].split(';')[0]; const state = new URL(started.headers.get('Location')).searchParams.get('state'); diff --git a/server/test/session.test.js b/server/test/session.test.js index 4e66898..a83b5bf 100644 --- a/server/test/session.test.js +++ b/server/test/session.test.js @@ -11,16 +11,16 @@ import { const NOW = Date.UTC(2026, 9, 9, 14, 2); -function req(path = '/admin/', { cookie, headers = {}, method = 'GET' } = {}) { +function req(path, { cookie, headers = {}, method = 'GET' } = {}) { const h = new Headers(headers); if (cookie) h.set('Cookie', cookie); return new Request(new URL(path, ORIGIN), { method, headers: h }); } -async function ctxFor(env, cookieHeader, { form = {}, now = NOW } = {}) { +async function ctxFor(env, cookieHeader, { form = {} } = {}) { const request = req('/auth/signout', { cookie: cookieHeader, method: 'POST' }); - const { user, perms } = await loadSession(env, request, now); - return { request, env, url: new URL(request.url), now, params: {}, + const { user, perms } = await loadSession(env, request, NOW); + return { request, env, url: new URL(request.url), now: NOW, params: {}, form: new URLSearchParams(form), user, perms }; } diff --git a/server/test/views.test.js b/server/test/views.test.js index 922b62e..bcb184c 100644 --- a/server/test/views.test.js +++ b/server/test/views.test.js @@ -1,13 +1,8 @@ // Views: every page renders inside the shared chrome with no script, every value is escaped, the nav // follows permissions, the sign-in page reads no D1 and never redirects, the changes form and href -// rule (case 17) and the import script round-trips. +// rule (case 17). import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { makeEnv, signIn, call, seedUser, HOUR } from './helpers.js'; import { freshDb } from './d1-shim.js'; import { page, navItems, alert, field } from '../views/layout.js'; @@ -19,14 +14,13 @@ import { HttpError } from '../http.js'; import { newId } from '../crypto.js'; const XSS = `'">`; -const ROOT = fileURLToPath(new URL('../../', import.meta.url)); const ALL = ['site:changes.read', 'site:changes.write', 'id:users.read', 'id:audit.read']; // A ctx for calling views directly. -function ctxFor({ perms = [], user = {}, path = '/admin/', method = 'GET' } = {}) { - const url = new URL(path, 'https://syberlabs.io'); +function ctxFor({ perms = [], user = {} } = {}) { + const url = new URL('/admin/', 'https://syberlabs.io'); return { - env: { ORIGIN: 'https://syberlabs.io' }, url, now: Date.now(), request: new Request(url, { method }), + env: { ORIGIN: 'https://syberlabs.io' }, url, now: Date.now(), request: new Request(url), form: new URLSearchParams(), perms: new Set(perms), user: { id: 'u1', identityId: 'i1', sessionHash: 'h', displayName: 'Test user', provider: 'github', login: 'test-user', email: null, expiresAt: Date.now() + HOUR, ...user }, }; @@ -485,40 +479,6 @@ test('What changed: reader sees rows escaped, no form; writer gets the form; 422 assert.equal(db.totalChanges(), before); }); -// ---- the import script ------------------------------------------------------------------------ - -test('changes-import.mjs: SQL with quotes round-trips; one bad entry refuses the run', async () => { - const dir = mkdtempSync(join(tmpdir(), 'views-test-')); - try { - const good = join(dir, 'good.js'); - writeFileSync(good, `export const latest = [ - { date: '2026-10-08', project: 'RISE', state: 'merged', title: "Reader's title", text: "It's done; DROP TABLE x; --", href: 'https://github.com/SyberLabs/x/pull/1' }, - { date: '2026-10-07', project: 'Site', state: 'in progress', title: 'Second', text: '', href: '/plus/' }, - ];\n`); - const sql = execFileSync(process.execPath, ['scripts/changes-import.mjs', '--file', good], { cwd: ROOT, encoding: 'utf8' }); - assert.equal(sql.split('\n')[0], '-- source good.js, 2 rows'); - const db = freshDb(); - db.sqlite.exec(sql); - const rows = db.sqlite.prepare('SELECT title, text, state FROM change_entries ORDER BY date DESC, created_at DESC').all(); - assert.deepEqual(rows.map(r => ({ ...r })), [ - { title: "Reader's title", text: "It's done; DROP TABLE x; --", state: 'merged' }, - { title: 'Second', text: '', state: 'in progress' }, - ]); - - const bad = join(dir, 'bad.js'); - writeFileSync(bad, `export const latest = [ - { date: '2026-10-08', project: 'RISE', state: 'merged', title: 'ok', text: '', href: '/plus/' }, - { date: '2026-10-08', project: 'RISE', state: 'merged', title: 'bad', text: '', href: 'javascript:alert(1)' }, - ];\n`); - const out = spawnSync(process.execPath, ['scripts/changes-import.mjs', '--file', bad], { cwd: ROOT, encoding: 'utf8' }); - assert.notEqual(out.status, 0); - assert.equal(out.stdout, ''); - assert.match(out.stderr, /entry 2/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - // Packet 5: display and freshness agree with the Add Google route, including the exact ten-minute boundary. test('Google account UI: fresh sessions can add it, older sessions reauthenticate, existing methods show success', async () => { const env = makeEnv({ GOOGLE_CLIENT_ID: 'google-test', GOOGLE_CLIENT_SECRET: 'google-secret' }); diff --git a/server/views/account.js b/server/views/account.js index 52f332e..9d5de0f 100644 --- a/server/views/account.js +++ b/server/views/account.js @@ -9,13 +9,13 @@ import { GITHUB_MARK } from './chrome.js'; import { contact } from './signin.js'; // RFC-0002 3.4: the codes the Add Google callback sends back here (oauth.js), with the account page's copy. -export const ADD_GOOGLE_COPY = { +const ADD_GOOGLE_COPY = { cancelled: ['neutral', () => 'Adding Google was cancelled.'], expired: ['warning', () => "Adding Google couldn't be finished. Try again."], provider: ['danger', () => `Google didn't finish the sign-in. Try again, or ${contact('email SyberLabs')}.`], google_taken: ['warning', () => 'That Google account is already on another SyberLabs account. Choose a different one.'], }; -export const GOOGLE_ADDED = 'Google is added. You can now sign in with GitHub or Google.'; +const GOOGLE_ADDED = 'Google is added. You can now sign in with GitHub or Google.'; // The notice from ?added=google or ?e=. "added" shows only when Google really is among the methods, so a // crafted link cannot show it (RFC-0002 3.3). diff --git a/server/views/audit.js b/server/views/audit.js index 556c54c..e667389 100644 --- a/server/views/audit.js +++ b/server/views/audit.js @@ -10,7 +10,7 @@ const LIMIT = 200; // Every action the app writes (and session.revoke, which older rows hold), with the verb phrase the page // shows: "turn off", never "disable" (RFC-0002 3.5). t = the target's name, role = a role name, title = an // entry title, all already escaped; provider = "Google" (a fixed label) for identity.add. -export const ACTION_SENTENCES = { +const ACTION_SENTENCES = { 'signin.ok': () => 'signed in', 'user.add': v => `added ${v.t}${v.role ? ` as ${v.role}` : ''}`, bootstrap: () => 'bootstrapped the first accounts', diff --git a/server/views/changes.js b/server/views/changes.js index 7cdd1a4..c2c3a7a 100644 --- a/server/views/changes.js +++ b/server/views/changes.js @@ -7,7 +7,7 @@ import { auditStmt } from '../authz.js'; import { page, head, alert, field, errorSummary, fixPrefix, postButton } from './layout.js'; import { confirmHtml, confirmed } from './confirm.js'; -export const STATES = ['deployed', 'merged', 'decided', 'recorded', 'in progress']; +const STATES = ['deployed', 'merged', 'decided', 'recorded', 'in progress']; const LIMIT = 100; const COLUMNS = 'id, date, project, state, title, text, href'; @@ -29,8 +29,8 @@ export function validHref(raw) { const realDate = s => /^\d{4}-\d{2}-\d{2}$/.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s; -export const LABELS = { date: 'Date', project: 'Project', state: 'State', title: 'Title', text: 'Text', href: 'Link' }; -export const MESSAGES = { +const LABELS = { date: 'Date', project: 'Project', state: 'State', title: 'Title', text: 'Text', href: 'Link' }; +const MESSAGES = { date: 'Use a date like 2026-10-09.', project: 'Projects are 1 to 60 characters.', state: 'Choose a state.', @@ -39,7 +39,7 @@ export const MESSAGES = { href: 'Links must start with https:// or a single /.', }; -// Validates one entry (form or import). Returns {entry, errors}; errors maps field -> message. +// Validates one entry (form). Returns {entry, errors}; errors maps field -> message. export function validateEntry(input) { const get = k => (typeof input[k] === 'string' ? input[k].trim() : input[k] == null ? '' : String(input[k]).trim()); const entry = { date: get('date'), project: get('project'), state: get('state'), title: get('title'), text: get('text'), href: get('href') }; diff --git a/server/views/error.js b/server/views/error.js index d2370ea..6ba43a3 100644 --- a/server/views/error.js +++ b/server/views/error.js @@ -30,7 +30,7 @@ export function errorHtml(ctx, err) { return page(ctx, { title, body }); } -export const STAFF_UNAVAILABLE_HTML = `Staff pages are unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`; +const STAFF_UNAVAILABLE_HTML = `Staff pages are unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`; // No ctx: this renders when config or D1 is missing, so it touches neither (RFC-0002 2.1, 3.4). // Under /admin: "Staff pages are unavailable", with the lockup and Sign out but no account line or nav, diff --git a/server/views/people.js b/server/views/people.js index 6312f44..d86e3ba 100644 --- a/server/views/people.js +++ b/server/views/people.js @@ -110,7 +110,7 @@ function addForm(ctx, data, values, errors) { } // The page, with an optional notice (trusted HTML) and the Add person form's values and errors. -export async function renderPeople(ctx, { status = 200, notice = '', values = {}, errors = {} } = {}) { +async function renderPeople(ctx, { status = 200, notice = '', values = {}, errors = {} } = {}) { const data = await loadPeople(ctx.env.DB); const manage = can(ctx, 'id:users.manage'); const ok = NOTICES[ctx.url.searchParams.get('ok')]; diff --git a/server/views/roles.js b/server/views/roles.js index d62f2aa..df544f6 100644 --- a/server/views/roles.js +++ b/server/views/roles.js @@ -40,7 +40,7 @@ function keyBoxes(selected) {
`; } -export const NAME_COPY = 'Role names are 2 to 40 lowercase letters, digits or hyphens, starting with a letter, like editor.'; +const NAME_COPY = 'Role names are 2 to 40 lowercase letters, digits or hyphens, starting with a letter, like editor.'; function roleForm(editing, values, errors) { const selected = new Set(values.perms || (editing ? editing.keys : [])); @@ -61,7 +61,7 @@ function roleForm(editing, values, errors) { } // A list of roles, each with its description, keys and holders (RFC-0002 2.6; no tables on staff pages). -export async function renderRoles(ctx, { status = 200, notice = '', values = {}, errors = {}, editId } = {}) { +async function renderRoles(ctx, { status = 200, notice = '', values = {}, errors = {}, editId } = {}) { const roles = await loadRoles(ctx.env.DB); const manage = can(ctx, 'id:roles.manage'); const editing = manage ? roles.find(r => r.id === (editId ?? ctx.url.searchParams.get('edit')) && r.is_system !== 1) : null; diff --git a/server/views/signin.js b/server/views/signin.js index e76236d..3182bf0 100644 --- a/server/views/signin.js +++ b/server/views/signin.js @@ -33,11 +33,11 @@ export const ERROR_COPY = { }; // The 503 copy on this page (RFC-0002 3.4): shown in place, with the buttons hidden. -export const UNAVAILABLE_HTML = `Sign-in is unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`; +const UNAVAILABLE_HTML = `Sign-in is unavailable right now. Reload in a minute, or ${contact('email SyberLabs')}.`; // The GitHub POST form: full width, 48 px, solid, because it is the only way in (RFC-0002 3.2). switch=1 asks // GitHub for its account picker. -export function githubForm({ next, switching } = {}) { +function githubForm({ next, switching } = {}) { const fields = hidden([['next', next], ['switch', switching ? '1' : '']]); const button = ``; return `
${fields}${button}
`; @@ -45,7 +45,7 @@ export function githubForm({ next, switching } = {}) { // Google's button, under GitHub's and centred (RFC-0002 3.2). staff.css resets the `; } @@ -73,7 +73,7 @@ export function signinBody(opts) { } // "Sign-in was cancelled · Sign in", so a screen reader announces the alert on load. -export function signinTitle(code, provider) { +function signinTitle(code, provider) { const copy = ERROR_COPY[code]; return copy ? `${copy.title({ provider })} · Sign in` : 'Sign in'; } diff --git a/services/index.html b/services/index.html index 15f4b3d..63e135e 100644 --- a/services/index.html +++ b/services/index.html @@ -19,8 +19,8 @@ - - + + Software Engineering / Work with Mateo | SyberLabs diff --git a/services/services.css b/services/services.css index 91129ba..2737883 100644 --- a/services/services.css +++ b/services/services.css @@ -11,8 +11,7 @@ main h1, main h2 { text-wrap: balance; } .intro { padding-top: var(--space-9); padding-bottom: var(--space-9); } .intro h1 { max-width: 900px; margin: var(--space-4) 0 0; font-family: var(--font-display); font-weight: 400; font-size: clamp(44px, 5vw, 72px); line-height: 1; letter-spacing: -.04em; } .lead { max-width: 640px; margin: var(--space-5) 0 0; font-size: 18px; line-height: 28px; color: var(--text-2); } -.actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); margin-top: var(--space-6); } -.intro-note { max-width: var(--measure); margin: var(--space-5) 0 0; font-size: 14px; line-height: 20px; color: var(--text-3); } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); } .inquiry { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-8); padding-top: var(--space-9); padding-bottom: var(--space-10); } .inquiry h2 { max-width: 760px; margin: var(--space-4) 0 0; font-family: var(--font-display); font-weight: 400; font-size: 40px; line-height: 44px; letter-spacing: -0.01em; } @@ -21,8 +20,6 @@ main h1, main h2 { text-wrap: balance; } .inquiry-copy .email-fallback { font-size: 14px; overflow-wrap: anywhere; } .inquiry-brief { border-left: 1px solid var(--line); padding-left: var(--space-7); } .inquiry-brief h3 { margin: 0 0 var(--space-4); font-size: 20px; font-weight: 500; } -.inquiry-brief ol { margin: 0; padding-left: 1.25em; color: var(--text-2); } -.inquiry-brief li { padding-left: .35em; margin-bottom: var(--space-3); line-height: 24px; } .inquiry .privacy { margin: var(--space-4) 0 0; font-size: 14px; line-height: 22px; color: var(--text-2); } @media (max-width: 860px) { diff --git a/src/home.css b/src/home.css index 7753d35..f2172ab 100644 --- a/src/home.css +++ b/src/home.css @@ -4,8 +4,7 @@ /* ---------- hero: the promise and one call to action; the Orb beside it (src/site/orb.js) ---------- */ /* Orb geometry before (or without) the engine, which then sets --cx/--cy/--s from the DOM: the orb sits right of the 640px copy column, 78% of the hero's height at most. */ -.home-hero { position: relative; isolation: isolate; overflow: hidden; min-height: 100vh; min-height: 100svh; display: flex; flex-direction: column; } -@media (min-width: 901px) { .home-hero { min-height: min(100svh, 860px); } } +.home-hero { position: relative; isolation: isolate; overflow: hidden; display: flex; flex-direction: column; } .home-hero__art { --tr: calc(max((100vw - var(--sy-max)) / 2, 0px) + var(--sy-gutter) + 640px); --s: min(78vh, (100vw - var(--tr)) * .86); --cx: calc(var(--tr) + (100vw - var(--tr)) / 2); --cy: 47%; position: absolute; inset: 0; pointer-events: none; } /* the orb's own atmosphere: a cool glow under it, no violet cast (the kit's nebula is overridden here) */ .home-hero.sy-nebula, html.sy-field-live .home-hero { background: radial-gradient(34% 46% at var(--cx, 76%) var(--cy, 47%), rgba(2, 151, 222, .16), #0000 70%); } @@ -18,7 +17,7 @@ .home-hero__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 26px; margin-top: 32px; } .home-hero__explore { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; color: var(--sy-vellum); font-weight: 500; background: linear-gradient(var(--sy-ice), var(--sy-ice)) 0 calc(100% - 10px)/100% 1px no-repeat; } .home-hero__explore:hover { color: var(--sy-ice); text-decoration: none; } -.home-hero a:focus-visible, .home-sec a:focus-visible { outline: 2px solid var(--sy-ice); outline-offset: 3px; } +.home-hero a:focus-visible { outline: 2px solid var(--sy-ice); outline-offset: 3px; } /* the typing bar: one quiet line under the orb; it exists only while the engine runs */ .home-orb__bar { display: none; position: absolute; z-index: 1; left: var(--cx); top: calc(var(--cy) + var(--s) / 2 + 22px); width: min(360px, calc(var(--s) * .82)); transform: translateX(-50%); flex-direction: column; align-items: center; gap: 8px; margin: 0; pointer-events: auto; } .is-live .home-orb__bar { display: flex; } @@ -47,10 +46,10 @@ } /* ---------- Pointillism: the lab's forms, one field of dots (src/site/pointillism.js) ---------- */ -.home-forms { padding-top: 112px; scroll-margin-top: 24px; } +.home-forms { scroll-margin-top: 24px; } .home-forms__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; } .home-forms__note { margin: 0 0 4px; max-width: 30em; text-align: right; color: var(--sy-mist); } -.home-forms__stage { position: relative; margin: 28px 0 0; height: clamp(380px, 58vw, 600px); border-radius: 14px; overflow: hidden; background: radial-gradient(60% 70% at 50% 48%, rgba(2, 151, 222, .12), #0000 72%), var(--sy-ink); border: 1px solid var(--sy-rule); } +.home-forms__stage { position: relative; margin: 28px 0 0; height: clamp(380px, 58vw, 600px); overflow: hidden; border: 1px solid var(--sy-rule); } .home-forms__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: pan-y; } /* the beat's sentence: centred under the forms (the engine keeps 70px clear for it), fading in with each beat */ .home-forms__now { position: absolute; left: 0; right: 0; bottom: 22px; padding: 0 24px; text-align: center; color: var(--sy-vellum); font: 400 clamp(20px, 2.2vw, 28px)/1.25 var(--sy-serif); text-wrap: balance; pointer-events: none; } @@ -60,32 +59,29 @@ .home-forms__stage:not(.is-live) .home-forms__now { display: none; } .home-forms__controls { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; margin-top: 16px; } .home-forms__chips { display: flex; flex-wrap: wrap; gap: 8px; } -.home-forms__chip, .home-forms__pause { min-height: 40px; padding: 8px 14px; border: 1px solid var(--sy-rule-2); border-radius: 999px; background: none; color: var(--sy-mist); font: 400 13px/1 var(--sy-sans); cursor: pointer; transition: color var(--sy-t-hover), border-color var(--sy-t-hover), background var(--sy-t-hover); } +.home-forms__chip, .home-forms__pause { min-height: 40px; padding: 8px 14px; border: 1px solid var(--sy-rule-2); background: none; color: var(--sy-mist); font: 400 13px/1 var(--sy-sans); cursor: pointer; transition: color var(--sy-t-hover), border-color var(--sy-t-hover), background var(--sy-t-hover); } .home-forms__chip:hover, .home-forms__pause:hover { color: var(--sy-vellum); border-color: color-mix(in srgb, var(--sy-ice) 50%, var(--sy-rule-2)); } .home-forms__chip[aria-pressed="true"] { color: var(--sy-ink); background: var(--sy-ice); border-color: var(--sy-ice); } .home-forms__pause { font-family: var(--sy-mono); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } .home-forms :is(button):focus-visible, .home-hero__cta:focus-visible { outline: 2px solid var(--sy-ice); outline-offset: 3px; } -@media (max-width: 800px) { .home-forms { padding-top: 80px; } .home-forms__head { flex-direction: column; align-items: flex-start; } .home-forms__note { text-align: left; } } -@media (max-width: 640px) { .home-forms__stage { height: 420px; } .home-forms__now { bottom: 14px; padding: 0 14px; font-size: 19px; } .home-forms__chip { padding: 7px 12px; font-size: 12px; min-height: 36px; } .home-me { margin-top: 72px; } } +@media (max-width: 800px) { .home-forms__head { flex-direction: column; align-items: flex-start; } .home-forms__note { text-align: left; } } +@media (max-width: 640px) { .home-forms__stage { height: 420px; } .home-forms__now { bottom: 14px; padding: 0 14px; font-size: 19px; } .home-forms__chip { padding: 7px 12px; font-size: 12px; min-height: 36px; } } /* Hiring-first cinematic stage. The still is an actual Gyre capture, not a product mockup. */ -.pitch-hero { position:relative; min-height: min(100svh, 920px); height: max(720px, 95svh); isolation:isolate; overflow:hidden; background:var(--sy-ink); } +.pitch-hero { position:relative; isolation:isolate; overflow:hidden; background:var(--sy-ink); } .pitch-hero__art { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center; } -.pitch-hero::after { content:""; position:absolute; inset:0; z-index:1; pointer-events:none; background:linear-gradient(0deg,rgba(2,7,10,.5) 0%,rgba(2,7,10,.1) 55%,rgba(2,7,10,.08) 100%); } -.pitch-hero__copy { position:relative; z-index:2; width:100%; padding-top:clamp(190px,27vh,320px); padding-bottom:150px; } -.pitch-hero__role { margin:0 0 18px; color:var(--sy-body); font-size:14px; letter-spacing:.08em; } -.pitch-hero h1 { margin:0 0 22px; font:500 clamp(48px,6.3vw,94px)/1.02 var(--sy-sans); letter-spacing:-.055em; } -.pitch-hero__line { margin:0; color:var(--sy-body); font:400 clamp(30px,3.35vw,50px)/1.13 var(--sy-sans); letter-spacing:-.035em; } -.pitch-hero__actions { display:flex; flex-wrap:wrap; gap:14px; margin-top:36px; } +.pitch-hero::after { content:""; position:absolute; inset:0; z-index:1; pointer-events:none; } +.pitch-hero__copy { position:relative; z-index:2; width:100%; } +.pitch-hero h1 { font:500 clamp(48px,6.3vw,94px)/1.02 var(--sy-sans); letter-spacing:-.055em; } +.pitch-hero__line { margin:0; font:400 clamp(30px,3.35vw,50px)/1.13 var(--sy-sans); } +.pitch-hero__actions { display:flex; flex-wrap:wrap; } .pitch-hero__actions .sy-btn { min-height:52px; padding:14px 24px; font-size:15px; border-radius:2px; letter-spacing:0; text-transform:none; } -.pitch-hero__actions .sy-btn--line { border-color:var(--sy-rule-2); background:rgba(2,7,10,.27); color:var(--sy-vellum); } -.pitch-hero__availability { margin:20px 0 0; color:var(--sy-mist); font-size:13px; } .pitch-hero__film { position:absolute; inset:0; z-index:3; width:100%; height:100%; object-fit:contain; background:var(--sy-ink); } .sy-js .pitch-hero:not(.is-playing) .pitch-hero__film { opacity:0; pointer-events:none; } .pitch-hero.is-playing .pitch-hero__copy,.pitch-hero.is-playing .pitch-hero__art { visibility:hidden; } .pitch-hero.is-playing::after { display:none; } -.pitch-hero__play { position:absolute; z-index:4; right:var(--sy-gutter); bottom:64px; display:flex; align-items:center; gap:16px; border:0; padding:8px; background:none; color:var(--sy-vellum); font:400 18px/1.2 var(--sy-sans); cursor:pointer; } +.pitch-hero__play { position:absolute; z-index:4; right:var(--sy-gutter); bottom:64px; display:flex; align-items:center; gap:16px; border:0; padding:8px; color:var(--sy-vellum); font:400 18px/1.2 var(--sy-sans); cursor:pointer; } .pitch-hero__play>span { display:grid; place-items:center; width:64px; height:64px; border:1px solid var(--sy-vellum); border-radius:50%; font-size:21px; } .pitch-hero__play small { font-size:14px; color:var(--sy-mist); } .pitch-hero.is-playing .pitch-hero__play { display:none!important; } @@ -121,8 +117,8 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh .home-forms h2 { font-family:var(--sy-sans); } .home-forms em { font-style:normal; color:var(--sy-vellum); } #main :is(a,button,summary,video):focus-visible { outline:2px solid var(--sy-ice); outline-offset:5px; } -@media(max-width:800px) { .pitch-hero { height:850px; min-height:100svh; } .pitch-hero__copy { padding-top:220px; } .pitch-hero__art { object-position:center; } .pitch-hero::after { background:linear-gradient(0deg,rgba(2,7,10,.5) 0%,rgba(2,7,10,.1) 65%); } .selected-work { gap:32px; } .demo-work article { gap:32px; } } -@media(max-width:600px) { .pitch-hero { height:760px; min-height:92svh; } .pitch-hero__copy { padding-top:190px; padding-bottom:170px; } .pitch-hero__role { font-size:12px; letter-spacing:.03em; } .pitch-hero h1 { font-size:clamp(44px,11vw,66px); white-space:nowrap; } .pitch-hero__line { font-size:34px; } .pitch-hero__actions { gap:10px; margin-top:28px; } .pitch-hero__actions .sy-btn { padding:12px 16px; font-size:14px; } .pitch-hero__play { left:var(--sy-gutter); right:auto; bottom:48px; font-size:17px; gap:12px; } .pitch-hero__play>span { width:54px; height:54px; } .pitch-hero__film { object-fit:contain; } .selected-work { grid-template-columns:1fr; padding-block:60px; } .demo-work article { grid-template-columns:1fr; gap:20px; padding-bottom:60px; } .demo-work { padding-top:60px; } .home-contact { flex-direction:column; align-items:flex-start; padding-block:60px; gap:20px; } .home-contact a { font-size:16px; } .film-notes { padding-top:8px; } } +@media(max-width:800px) { .selected-work { gap:32px; } .demo-work article { gap:32px; } } +@media(max-width:600px) { .pitch-hero h1 { font-size:clamp(44px,11vw,66px); white-space:nowrap; } .pitch-hero__actions .sy-btn { padding:12px 16px; font-size:14px; } .pitch-hero__play { left:var(--sy-gutter); right:auto; font-size:17px; gap:12px; } .pitch-hero__play>span { width:54px; height:54px; } .selected-work { grid-template-columns:1fr; padding-block:60px; } .demo-work article { grid-template-columns:1fr; gap:20px; padding-bottom:60px; } .demo-work { padding-top:60px; } .home-contact { flex-direction:column; align-items:flex-start; padding-block:60px; gap:20px; } .home-contact a { font-size:16px; } .film-notes { padding-top:8px; } } .home-orb__pause { min-height:44px; border:0; padding:8px; background:none; color:var(--sy-ice); font:13px var(--sy-sans); cursor:pointer; } @@ -136,17 +132,16 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh .pitch-hero__art img { width:100%; height:100%; object-fit:cover; object-position:center; } .pitch-hero__background { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center; opacity:0; pointer-events:none; } -.pitch-hero__background.is-ready { opacity:1; } .pitch-hero.is-playing .pitch-hero__background, .pitch-hero.is-playing .pitch-hero__clip { visibility:hidden; } .pitch-hero__clip { position:absolute; inset:0; pointer-events:none; } -.pitch-hero__still { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center; opacity:.94; } +.pitch-hero__still { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center; } .pitch-hero__motion { position:absolute; top:88px; right:var(--sy-gutter); z-index:4; min-height:44px; padding:10px 14px; border:1px solid var(--sy-rule-2); border-radius:2px; background:rgba(2,7,10,.79); color:var(--sy-vellum); font:12px var(--sy-sans); cursor:pointer; } .pitch-hero__play { background:rgba(2,7,10,.72); border-radius:2px; } -@media(max-width:600px) { .pitch-hero__art img,.pitch-hero__background { object-position:center; } .pitch-hero__motion { top:82px; font-size:11px; padding:8px 10px; } } +@media(max-width:600px) { .pitch-hero__motion { font-size:11px; padding:8px 10px; } } /* Full-canvas motion stays visible behind the copy; contrast is local to text and controls. */ .pitch-hero__copy { text-shadow:0 2px 3px #000e,0 0 18px #000c; } -.pitch-hero__role,.pitch-hero__line,.pitch-hero__availability { color:var(--sy-vellum); } +.pitch-hero__role { color:var(--sy-vellum); } .pitch-hero__actions .sy-btn { text-shadow:none; box-shadow:0 2px 14px #0006; } .hero-credit { color:var(--sy-dim); font-size:11px; transition:opacity .6s ease; } .hero-credit.is-off { opacity:0; } @@ -154,7 +149,7 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh /* The hero is one composition. The clip (Gyre, the RISE Sophia frame drawing itself, Earth held in it, back to Gyre) sits right of the copy with its left edge dissolving into the stage, so Earth never sits under type. The name - stands on its own; the line, actions and availability sit together in one dark glass panel beneath it. */ + stands on its own; the line and actions sit together in one dark glass panel beneath it. */ .pitch-hero { height:auto; min-height:max(680px,100svh); } .pitch-hero::after { background:linear-gradient(90deg,rgba(2,7,10,.65) 0%,rgba(2,7,10,0) 48%),linear-gradient(0deg,rgba(2,7,10,.6) 0%,rgba(2,7,10,0) 34%); } .pitch-hero__copy { display:flex; flex-direction:column; justify-content:center; min-height:max(680px,100svh); padding-top:120px; padding-bottom:150px; } @@ -175,24 +170,13 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh -webkit-background-clip:text; background-clip:text; animation:brand-sheen 2.6s cubic-bezier(.3,.6,.2,1) .9s both; } @keyframes brand-sheen { from { background-position:-160% 0,0 0; } to { background-position:260% 0,0 0; } } .pitch-hero__brand:hover::after { animation:brand-sheen 1.8s cubic-bezier(.3,.6,.2,1) both; } -/* by, signed: the signature writes itself in from the left */ -.pitch-hero__by { display:flex !important; align-items:center; gap:14px; margin-top:6px; padding-left:.3em; color:var(--sy-body); font:400 18px/1 var(--sy-sans); letter-spacing:.02em; } -.pitch-hero__signature { width:clamp(220px,24vw,340px); height:auto; margin:-14px 0 -22px; filter:drop-shadow(0 2px 3px #000c); } -.sy-reveal .pitch-hero__signature { animation:signature-write 1.9s cubic-bezier(.45,.1,.25,1) 1s both; } -@keyframes signature-write { from { clip-path:inset(0 100% 0 0); } to { clip-path:inset(0 0 0 0); } } -.pitch-hero__role { margin-top:26px; } -/* The call to action, with restraint: no box. One quiet line, a compact primary action, the résumé as a text link and - availability as a status line. The copy's own text shadow keeps it legible over the motion. */ -.pitch-hero__panel { width:min(100%,460px); } -.pitch-hero__actions .sy-btn--solid { min-height:44px; padding:0 20px; font-size:14px; font-weight:500; } +/* The call to action, with restraint: no box. One quiet line, a compact primary action and the résumé as a text link. + The copy's own text shadow keeps it legible over the motion. */ +.pitch-hero__actions .sy-btn--solid { min-height:44px; padding:0 20px; font-size:14px; } .pitch-hero__actions .sy-btn--line { min-height:44px; padding:0 2px; border:0; background:none; box-shadow:none; color:var(--sy-vellum); font-size:14px; text-decoration:underline; text-decoration-color:rgba(2,151,222,.75); text-underline-offset:5px; text-shadow:inherit; } .pitch-hero__actions .sy-btn--line:hover { text-decoration-color:var(--sy-electric-hi); color:var(--sy-electric-hi); box-shadow:none; } -.pitch-hero__availability { display:flex; align-items:center; gap:8px; } -.pitch-hero__availability::before { content:""; width:6px; height:6px; border-radius:50%; background:var(--sy-cantaloupe); box-shadow:0 0 8px rgba(252,115,77,.7); } .pitch-hero__line { font-size:19px; line-height:1.45; letter-spacing:-.01em; max-width:28ch; color:var(--sy-body); } .pitch-hero__actions { align-items:center; gap:10px 22px; margin-top:20px; } -.pitch-hero__availability { margin-top:14px; color:var(--sy-mist); font-size:12px; } -.pitch-hero__art, .pitch-hero__background.is-ready { opacity:.94; } @media(min-width:801px) { /* scaled into a soft vignette: the frame and Earth are held whole, right of the copy, never cropped */ .pitch-hero__art, .pitch-hero__clip { transform:translateX(21%) scale(.72); -webkit-mask-image:linear-gradient(90deg,transparent 0,#000 30%,#000 86%,transparent 100%),linear-gradient(0deg,transparent 0,#000 14%,#000 86%,transparent 100%); -webkit-mask-composite:source-in; mask-image:linear-gradient(90deg,transparent 0,#000 30%,#000 86%,transparent 100%),linear-gradient(0deg,transparent 0,#000 14%,#000 86%,transparent 100%); mask-composite:intersect; } @@ -217,26 +201,17 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh .pitch-hero::after { background:linear-gradient(180deg,rgba(2,7,10,.7) 0%,rgba(2,7,10,0) 22%,rgba(2,7,10,0) 40%,rgba(2,7,10,.8) 100%); } } @media(max-width:600px) { - .pitch-hero__role { font-size:13px; margin-bottom:10px; } + .pitch-hero__role { margin-bottom:10px; } .pitch-hero h1 { margin:0; } .pitch-hero__brand { font-size:clamp(50px,14vw,60px); } - .pitch-hero__by { gap:10px; font-size:14px; margin-top:2px; } - .pitch-hero__signature { width:min(190px,48vw); margin:-10px 0 -16px; } - .pitch-hero__role { margin-top:20px; } /* clear of the signature's lower swash */ .pitch-hero__motion { top:auto; bottom:42px; right:var(--sy-gutter); } /* a compact panel: one line of copy, the two actions side by side at the minimum touch height, one quiet line */ - .pitch-hero__panel { width:100%; } .pitch-hero__line { font-size:15px; line-height:1.45; max-width:30ch; } .pitch-hero__actions { flex-wrap:nowrap; gap:20px; margin-top:14px; } - .pitch-hero__availability { font-size:12px; margin-top:10px; } .pitch-hero__play { bottom:30px; } - /* the frame held between the name and the panel, fading out before the clip's edges */ - /* the Gyre alone, full-bleed behind the copy, dimmed a little so the title and the panel stay crisp */ - .pitch-hero__art, .pitch-hero__clip { transform:none; -webkit-mask-image:linear-gradient(180deg,#000 0,#000 72%,transparent 100%); mask-image:linear-gradient(180deg,#000 0,#000 72%,transparent 100%); } - .pitch-hero__art, .pitch-hero__background.is-ready, .pitch-hero__still { opacity:.7; } } @media(prefers-reduced-motion:reduce) { - .pitch-hero h1>span, .pitch-hero__role, .pitch-hero__panel, .pitch-hero__brand, .pitch-hero__brand::after, .pitch-hero__signature { transition:none; animation:none; } + .pitch-hero h1>span, .pitch-hero__role, .pitch-hero__panel, .pitch-hero__brand, .pitch-hero__brand::after { transition:none; animation:none; } } /* The Sophia frame's painting: a disc laid exactly over Earth's (placed by BackgroundVideo.jsx from the clip's @@ -275,7 +250,6 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh /* Proof first: the name in the chromatic treatment, the role, then the three facts a recruiter checks, each linked to where it can be verified. The list reads as plain text without CSS or JavaScript. */ -.pitch-hero__name { font-size:clamp(56px,6.4vw,100px); } .pitch-hero__role { font-size:16px; letter-spacing:.04em; } .pitch-hero__proof { display:grid; gap:9px; margin:20px 0 0; padding:0; list-style:none; } .pitch-hero__proof li { display:grid; grid-template-columns:96px 1fr; gap:14px; align-items:baseline; font-size:15px; line-height:1.4; } @@ -283,20 +257,15 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh .pitch-hero__proof a { color:var(--sy-vellum); text-decoration:underline; text-decoration-color:rgba(2,151,222,.55); text-underline-offset:4px; } .pitch-hero__proof a:hover { color:var(--sy-electric-hi); text-decoration-color:var(--sy-electric-hi); } .pitch-hero h1 + .pitch-hero__role { margin-top:6px; } -.pitch-hero h1:has(.pitch-hero__name) { margin-bottom:0; } .pitch-hero__panel { width:min(100%,600px); } -.pitch-hero__availability { flex-wrap:wrap; gap:4px 8px; } .pitch-hero__role a { color:inherit; text-decoration:underline; text-decoration-color:rgba(2,151,222,.75); text-underline-offset:4px; } .pitch-hero__role a:hover { text-decoration-color:currentColor; } -.pitch-hero__availability a { margin-left:6px; color:inherit; text-decoration:underline; text-decoration-color:var(--sy-rule-2); text-underline-offset:3px; } .home-contact__signature { display:block; width:min(220px,50vw); height:auto; margin:6px 0 10px -6px; opacity:.85; } @media(max-width:600px) { - .pitch-hero__name { font-size:clamp(40px,11.2vw,52px); } .pitch-hero__role { font-size:14px; } .pitch-hero__proof { gap:7px; margin-top:12px; } .pitch-hero__proof li { grid-template-columns:78px 1fr; gap:10px; font-size:13px; } .pitch-hero__proof-label { font-size:10px; } - .pitch-hero__availability a { flex-basis:100%; margin-left:14px; } } /* PorchLight, the hackathon build: the GitHits layout, with its small lantern mark beside the name */ @@ -306,20 +275,18 @@ html:not(.sy-js) .pitch-hero__film { position:relative; aspect-ratio:16/9; heigh /* RISE: an example PR and the README credit side by side */ .demo-work__links { display:flex; flex-wrap:wrap; gap:6px 24px; } -/* Review fixes (#170): the name never re-runs its entrance when the client render replaces the prerendered hero, so - a slow script cannot blank it; from 601 to 800 px the copy clears the Meet Mateo button so the email stays clickable. */ -.sy-reveal .pitch-hero h1>.pitch-hero__name { animation:none; } +/* Review fixes (#170): from 601 to 800 px the copy clears the Meet Mateo button so the email stays clickable. */ @media(min-width:601px) and (max-width:800px) { .pitch-hero__copy { padding-bottom:164px; } } /* The factory: the tagline under the chrome title, and the five steps in the factory section */ -.pitch-hero h1>.pitch-hero__tagline { display:block; white-space:normal; margin-top:14px; color:var(--sy-vellum); font:500 clamp(22px,2.4vw,34px)/1.15 var(--sy-sans); letter-spacing:-.02em; text-shadow:0 2px 3px #000e,0 0 18px #000c; } +.pitch-hero h1>.pitch-hero__tagline { display:block; white-space:normal; text-shadow:0 2px 3px #000e,0 0 18px #000c; } .factory-steps { display:grid; gap:0; margin:24px 0 0; padding:0; list-style:none; counter-reset:step; } .factory-steps li { counter-increment:step; display:grid; grid-template-columns:28px 92px 1fr; gap:12px; align-items:baseline; padding:12px 0; border-top:1px solid var(--sy-rule); font-size:14px; line-height:1.5; } .factory-steps li::before { content:counter(step,decimal-leading-zero); color:var(--sy-ice); font:400 11px var(--sy-mono); } .factory-steps b { color:var(--sy-vellum); font-weight:500; } .factory-steps span { color:var(--sy-mist); } .lab-identity__copy>p.factory-self { margin:20px 0 4px; color:var(--sy-dim); font-size:13px; line-height:1.6; max-width:46ch; } -@media(max-width:600px) { .pitch-hero h1>.pitch-hero__tagline { font-size:20px; margin-top:8px; } .factory-steps li { grid-template-columns:24px 1fr; } .factory-steps span { grid-column:2; } } +@media(max-width:600px) { .factory-steps li { grid-template-columns:24px 1fr; } .factory-steps span { grid-column:2; } } /* ---------- The headline, still: SyberLabs in chromatic blue over "High-Fidelity Systems", on the Gyre ---------- */ /* The Gyre is full-bleed on every screen, so it is never scaled into a frame or pushed aside. */ diff --git a/src/living-lab/scene.js b/src/living-lab/scene.js index 52d103e..bda2517 100644 --- a/src/living-lab/scene.js +++ b/src/living-lab/scene.js @@ -1,16 +1,8 @@ // A local, bounded text renderer. Text is never code, markup, a URL or a model prompt. export const MAX_CHARACTERS = 180; const MAX_CODE_UNITS = 2048; -export const SAMPLES = [ - { label: 'After the rain', text: 'After the rain, the city holds a thousand tiny suns.', atmosphere: 'spectrum' }, - { label: 'A small wonder', text: 'A small light finds its way through the dark, and everything begins again.', atmosphere: 'ember' }, - { label: 'Cosmic drift', text: 'Somewhere between the stars, a quiet thought becomes a world.', atmosphere: 'glacier' }, -]; const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; -// Older browsers retain a usable static reading; the component gates playback on full grapheme support. -export const supportsGraphemes = !!segmenter; -export const characters = text => segmenter ? [...segmenter.segment(text)].map(part => part.segment) : Array.from(text); export function limitText(text) { // A single combining cluster can be arbitrarily large. Bound work before segmentation, // and drop a cluster crossing the secondary ceiling rather than cutting through it. @@ -24,28 +16,3 @@ export function limitText(text) { return parts.join(''); } export const cleanText = text => limitText(text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+/gu, ' ').trim()); -export const longText = text => characters(text).length > 100 || text.split(/\s+/u).some(word => characters(word).length > 24); - -export function makeScene(input) { - const text = cleanText(input); - const phrases = []; - for (const clause of text.match(/[^.!?;,:。!?、,;:]*[.!?;,:。!?、,;:]+|[^.!?;,:。!?、,;:]+$/gu) || []) { - const words = clause.trim().split(/\s+/u); - if (words.length === 1 && characters(words[0]).length > 32) { - const glyphs = characters(words[0]); - for (let i = 0; i < glyphs.length; i += 32) phrases.push(glyphs.slice(i, i + 32).join('')); - continue; - } - for (let i = 0; i < words.length; i += 6) phrases.push(words.slice(i, i + 6).join(' ')); - } - if (text && !phrases.length) phrases.push(text); - // Even punctuation-heavy or one-letter input finishes in at most six holds. - const size = Math.ceil(phrases.length / 6); - const bounded = []; - for (let i = 0; i < phrases.length; i += size) bounded.push(phrases.slice(i, i + size).join(' ')); - return { text, phrases: bounded }; -} - -export function phraseDuration(phrase) { - return 1800 + Math.min(6, phrase.split(/\s+/u).length) * 180; -} diff --git a/src/site/chamber.js b/src/site/chamber.js deleted file mode 100644 index b1c1315..0000000 --- a/src/site/chamber.js +++ /dev/null @@ -1,87 +0,0 @@ -/* The hero chamber: a short reading about SyberLabs, performed in the middle of Plate I the way RISE's Stream view - performs a text. Each line arrives word by word in its own caption, holds, and lifts away as the next arrives, - and the reading loops. - - Only opacity and transform change (the words and captions are composited layers), so it costs the main thread a - few DOM writes per line. It pauses off screen, in a background tab, and while the Atlas covers the page. - Without JavaScript or under reduced motion the figure shows one line, still (the markup's own .is-still line). - - Markup (src/App.jsx Chamber): figure.home-chamber > .home-chamber__stage (captions are added here). - mountChamber(figure, lines) -> { destroy() } */ - -const WORD = 105; // ms between words arriving -const ARRIVE = 560; // ms for one word to settle -const HOLD = 1250; // ms a line holds once its last word has settled, plus 90ms per word to read it -const LEAVE = 520; // ms for a line to lift away (the next one starts arriving as it does) -const GAP = 1400; // ms of quiet before the reading starts over - -const lineMs = words => (words - 1) * WORD + ARRIVE + HOLD + words * 90; - -export function mountChamber(fig, lines) { - const reduced = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches); - const stage = fig?.querySelector('.home-chamber__stage'); - if (!stage || !lines?.length || reduced || !fig.animate) return { destroy() {} }; - - const words = lines.map(l => l.split(' ').length); - let i = 0, timer = 0, cur = null, visible = false, dead = false; - const paused = () => !visible || document.hidden || document.documentElement.classList.contains('sy-atlas-open'); - - const caption = text => { - const p = document.createElement('p'); - p.className = 'home-chamber__line'; - text.split(' ').forEach((w, k) => { - const s = document.createElement('span'); - s.textContent = w; s.style.setProperty('--k', k); - p.append(s, ' '); - }); - return p; - }; - - const show = n => { - const prev = cur; - if (prev) { prev.classList.add('is-out'); setTimeout(() => prev.remove(), LEAVE + 60); } - cur = caption(lines[n]); - stage.append(cur); - void cur.offsetWidth; // start from the hidden state - cur.classList.add('is-in'); - }; - - // one step of the reading: show line i, schedule the next (or the restart) - const step = () => { - if (dead) return; - if (i >= lines.length) { - if (cur) { cur.classList.add('is-out'); const c = cur; setTimeout(() => c.remove(), LEAVE + 60); cur = null; } - timer = setTimeout(() => { if (dead) return; i = 0; step(); }, GAP); - return; - } - show(i); - const ms = lineMs(words[i]); i++; - timer = setTimeout(step, ms); - }; - - const run = () => { if (!dead && !paused()) step(); }; - // stop between lines and resume from the start of the line that was playing - const halt = () => { clearTimeout(timer); timer = 0; i = Math.max(0, i - 1); }; - let running = false; - const sync = () => { - const want = !paused(); - if (want && !running) { running = true; fig.classList.add('is-live'); run(); } - else if (!want && running) { running = false; halt(); } - }; - - fig.classList.add('is-js'); - const io = 'IntersectionObserver' in window ? new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }, { threshold: 0.25 }) : null; - if (io) io.observe(fig); else { visible = true; sync(); } - const mo = new MutationObserver(sync); - mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); - document.addEventListener('visibilitychange', sync); - - return { - destroy() { - dead = true; clearTimeout(timer); - io?.disconnect(); mo.disconnect(); document.removeEventListener('visibilitychange', sync); - stage.querySelectorAll('.home-chamber__line:not(.is-still)').forEach(n => n.remove()); - fig.classList.remove('is-js', 'is-live'); - }, - }; -} diff --git a/src/site/factory-stats.js b/src/site/factory-stats.js deleted file mode 100644 index 237db15..0000000 --- a/src/site/factory-stats.js +++ /dev/null @@ -1,37 +0,0 @@ -/* The factory's live numbers (src/App.jsx Factory). Each [data-stat] tile carries its baked-in snapshot as - text and as data-count; this fills it from the public GitHub REST API after load, one request per tile - (search with per_page=1, read total_count). Unauthenticated: 60 requests an hour per IP, and a failed or - rate-limited request leaves the snapshot in place. The tile's [data-stat-src] caption says which it is. - Nothing here blocks render, and the count-up (site.js counters) still runs once, on reveal, from - whatever number the tile holds when it scrolls in. */ -const API = 'https://api.github.com'; -const day = ms => new Date(Date.now() - ms).toISOString().slice(0, 10); -const WEEK = 7 * 86400e3, DAY = 86400e3; - -const search = q => `${API}/search/issues?q=${encodeURIComponent(q)}&per_page=1`; -const queries = { - 'merged-7d': () => [search(`org:SyberLabs is:pr is:merged merged:>=${day(WEEK)}`), r => r.total_count], - 'reviewed-7d': () => [search(`org:SyberLabs is:pr commenter:chatgpt-codex-connector[bot] created:>=${day(WEEK)}`), r => r.total_count], - 'merged-24h': () => [search(`org:SyberLabs is:pr is:merged merged:>=${day(DAY)}`), r => r.total_count], - repos: () => [`${API}/orgs/SyberLabs`, r => r.public_repos], -}; - -async function fetchStat(name) { - const [url, pick] = queries[name](); - const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } }); - if (!res.ok) throw new Error(`${res.status} ${url}`); - const n = pick(await res.json()); - if (!Number.isFinite(n)) throw new Error(`no number in ${url}`); - return n; -} - -export function factoryStats(root = document) { - const tiles = [...root.querySelectorAll('[data-stat]')].filter(t => queries[t.dataset.stat]); - if (!tiles.length || typeof fetch !== 'function') return; - const run = () => tiles.forEach(tile => fetchStat(tile.dataset.stat).then(n => { - const num = tile.querySelector('[data-count]'), src = tile.querySelector('[data-stat-src]'); - if (num) { num.dataset.count = String(n); num.textContent = String(n); } - if (src) { src.textContent = 'live · from GitHub'; src.classList.add('is-live'); } - }).catch(() => {})); - if (document.readyState === 'complete') run(); else addEventListener('load', run, { once: true }); -} diff --git a/src/site/motion.js b/src/site/motion.js index 6a3e25e..0bac272 100644 --- a/src/site/motion.js +++ b/src/site/motion.js @@ -112,21 +112,6 @@ export function splitWords(el) { el.classList.add('sy-split'); } -export function headerScroll() { - const header = document.querySelector('.sy-header'); - if (!header) return; - let lastY = scrollY, raf = 0; - const paint = () => { - raf = 0; - const y = scrollY, down = y > lastY + 4, up = y < lastY - 4; - if (y < 80) header.classList.remove('is-hidden'); - else if (down) header.classList.add('is-hidden'); - else if (up) header.classList.remove('is-hidden'); - lastY = y; - }; - addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(paint); }, { passive: true }); -} - /* Spectrum text (syberlabs.css sy-hue) moves its gradient with a background-position animation, which the browser repaints rather than composites; pausing it off screen leaves only the words in view doing that work. */ export function hue(root = document) { diff --git a/src/site/orb.js b/src/site/orb.js index ea972a7..3042db3 100644 --- a/src/site/orb.js +++ b/src/site/orb.js @@ -39,7 +39,6 @@ const SOFT = /swiftshader|llvmpipe|softpipe|software|basic render/i; const lose = gl => gl.getExtension('WEBGL_lose_context')?.loseContext(); const rng32 = seed => () => { seed |= 0; seed = seed + 0x6d2b79f5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; const hash = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }; -const ss = x => x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x); // The forms a text calls up: a known-good attractor nudged by the characters, and four flame maps from its hash. function formsFor(text) { diff --git a/src/site/site.js b/src/site/site.js index c23efaa..da3aeb9 100644 --- a/src/site/site.js +++ b/src/site/site.js @@ -16,13 +16,13 @@ export function boot(root = document, fieldOpts) { } /* 87 counts up from 0 when it scrolls in. */ -export function counters(root = document) { +function counters(root = document) { const els = [...root.querySelectorAll('[data-count]')]; if (!els.length || reducedMotion() || !('IntersectionObserver' in window)) return; const io = new IntersectionObserver(es => es.forEach(e => { if (!e.isIntersecting) return; io.unobserve(e.target); const el = e.target, t0 = performance.now(), dur = 1400; - // data-count is read every frame: a live number that lands mid-count (factory-stats.js) ends the count where it should + // data-count is read every frame: a live number that lands mid-count ends the count where it should const tick = now => { const k = Math.min(1, (now - t0) / dur), v = Math.round(parseFloat(el.dataset.count) * (1 - Math.pow(1 - k, 3))); el.textContent = String(v); if (k < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }), { threshold: 0.4 }); @@ -30,7 +30,7 @@ export function counters(root = document) { } let field; -export function mountFieldLazy(fieldOpts = {}) { +function mountFieldLazy(fieldOpts = {}) { if (field || document.documentElement.dataset.field === 'off') return; let canvas = document.querySelector('canvas.sy-field'); if (!canvas) { @@ -48,7 +48,7 @@ export function mountFieldLazy(fieldOpts = {}) { } /* canvas[data-sigil3d="seed"] inside a plate: the product's de Jong parameters spun as a 3D cloud. */ -export function mountSigils3d(root = document) { +function mountSigils3d(root = document) { const list = [...root.querySelectorAll('canvas[data-sigil3d]')]; if (!list.length) return; import('./field.js').then(m => { @@ -60,5 +60,3 @@ export function mountSigils3d(root = document) { } }); } - -export { reducedMotion }; diff --git a/src/site/sketch-gallery.js b/src/site/sketch-gallery.js deleted file mode 100644 index d226d80..0000000 --- a/src/site/sketch-gallery.js +++ /dev/null @@ -1,15 +0,0 @@ -/* Grown in RISE Sketch: real timelapses from the engine, each a link that opens that exact drawing in - RISE Sketch (its remix link). Videos are muted loops that play only while on screen; reduced motion - leaves the poster still. */ -import { reducedMotion } from './motion.js'; - -export function mountSketchGallery(root) { - const videos = [...root.querySelectorAll('video[data-sketch-loop]')]; - if (!videos.length || reducedMotion() || !('IntersectionObserver' in window)) return { destroy() {} }; - const io = new IntersectionObserver(es => es.forEach(e => { - const v = e.target; - if (e.isIntersecting) { if (v.preload === 'none') v.preload = 'auto'; v.play().catch(() => {}); } else v.pause(); - }), { threshold: 0.35 }); - videos.forEach(v => io.observe(v)); - return { destroy() { io.disconnect(); } }; -} diff --git a/src/site/sketch-plate.js b/src/site/sketch-plate.js deleted file mode 100644 index b16c92a..0000000 --- a/src/site/sketch-plate.js +++ /dev/null @@ -1,264 +0,0 @@ -/* Plate IX · Living ink: a small drawable kaleidoscope that previews RISE Sketch (sketch.syberlabs.io). - Drag inside the plate and the stroke folds into a 12-way dihedral mandala (6 turns × mirror), each fold in - its own spectral hue; it then grows the way RISE Sketch Forms do, as fractal sprouts off the stroke and - Ripple-style contour rings that breathe and beat into moiré. When nobody is drawing, a ghost hand does. - - Canvas 2D, no dependencies. At most 60 frames a second; pauses off-screen, in hidden tabs and under the - Atlas menu. Reduced motion: strokes appear fully grown and nothing moves. The canvas is decorative (aria-hidden); the link beside it is the real action. */ - -const FOLDS = 6; // rotational order; each turn is also mirrored -const MAX_STROKES = 4; // older strokes fade out beyond this -const GROW_MS = 2600; // trunk → sprouts → rings -const IDLE_MS = 3800; // ghost hand starts after this long without input -const TAU = Math.PI * 2; - -/** Deterministic PRNG (mulberry32), so a stroke's sprouts never change while it lives. */ -function rng(seed) { - let a = seed >>> 0; - return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; -} - -/** Resample a polyline to points `step` apart (plate units). */ -function resample(pts, step) { - if (pts.length < 2) return pts.slice(); - const out = [pts[0]]; - let carry = 0; - for (let i = 1; i < pts.length; i++) { - let [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i]; - let seg = Math.hypot(x1 - x0, y1 - y0); - while (carry + seg >= step) { - const k = (step - carry) / seg; - x0 += (x1 - x0) * k; y0 += (y1 - y0) * k; seg = Math.hypot(x1 - x0, y1 - y0); - out.push([x0, y0]); carry = 0; - } - carry += seg; - } - const last = pts[pts.length - 1], tail = out[out.length - 1]; - if (Math.hypot(last[0] - tail[0], last[1] - tail[1]) > step * 0.3) out.push(last); - return out; -} - -/** Unit normals of a polyline (averaged at joints). */ -function normals(pts) { - return pts.map((_, i) => { - const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)]; - const dx = b[0] - a[0], dy = b[1] - a[1], l = Math.hypot(dx, dy) || 1; - return [-dy / l, dx / l]; - }); -} - -/** Fractal sprouts: recursive branches off the stroke, each generation shorter and more curled. */ -function sprouts(pts, nrm, seed) { - const r = rng(seed), out = []; - const grow = (x, y, ang, len, gen, born) => { - const seg = [[x, y]], curl = (r() - 0.5) * 0.9, n = 7; - for (let i = 1; i <= n; i++) { ang += curl / n; x += Math.cos(ang) * len / n; y += Math.sin(ang) * len / n; seg.push([x, y]); } - out.push({ pts: seg, gen, born }); - if (gen >= 4) return; - const kids = gen < 2 ? 2 : 1 + (r() < 0.5); - for (let k = 0; k < kids; k++) grow(x, y, ang + (k ? -1 : 1) * (0.45 + r() * 0.5), len * (0.55 + r() * 0.15), gen + 1, born + 0.12); - }; - for (let i = 2; i < pts.length - 1; i += 5 + Math.floor(r() * 4)) { - const side = r() < 0.5 ? -1 : 1, [nx, ny] = nrm[i], a = Math.atan2(ny * side, nx * side); - grow(pts[i][0], pts[i][1], a + (r() - 0.5) * 0.6, 0.06 + r() * 0.07, 1, i / pts.length * 0.45); - } - return out; -} - -function makeStroke(raw, seed, hue, born) { - const pts = resample(raw, 0.012), nrm = normals(pts); - return { pts, nrm, hue, born, branches: sprouts(pts, nrm, seed) }; -} - -/** A ghost-hand gesture: a petal, spiral or wave, somewhere in one wedge of the mandala. */ -function ghostPath(seed) { - const r = rng(seed), kind = Math.floor(r() * 3), pts = []; - const r0 = 0.12 + r() * 0.25, a0 = r() * TAU / FOLDS; - for (let i = 0; i <= 60; i++) { - const t = i / 60; - if (kind === 0) { const a = a0 + t * 1.6, rr = r0 + 0.32 * Math.sin(t * Math.PI); pts.push([Math.cos(a) * rr, Math.sin(a) * rr]); } - else if (kind === 1) { const a = a0 + t * 4.2, rr = 0.06 + t * (0.3 + r0 * 0.5); pts.push([Math.cos(a) * rr, Math.sin(a) * rr]); } - else { const rr = 0.1 + t * 0.62, a = a0 + 0.35 * Math.sin(t * 9 + r() * 0.2); pts.push([Math.cos(a) * rr, Math.sin(a) * rr]); } - } - return pts; -} - -const ease = k => 1 - Math.pow(1 - Math.min(1, Math.max(0, k)), 3); - -/** - * Mount the plate on a canvas. Returns { destroy }. - * reduced: strokes appear fully grown, drawn only on input; nothing animates. - */ -export function mountSketchPlate(canvas, { reduced = false } = {}) { - const ctx = canvas.getContext('2d'); - if (!ctx) return { destroy() {} }; - const GROWN = -Infinity; // birth time of a stroke that appears fully grown - let strokes = [], live = null, ghost = null, raf = 0, visible = true, lastInput = 0, seedN = 1, hue = 247; - let W = 0, H = 0, R = 0, dpr = 1; - // Lite mode: if frames run slow (no GPU canvas), drop the halo and the outer rings for good. - let lite = false, slow = 0, lastFrame = 0; - const GAP = 1000 / 60, html = document.documentElement; - - const size = () => { - const b = canvas.getBoundingClientRect(); - dpr = Math.min(2, window.devicePixelRatio || 1); - W = Math.max(1, Math.round(b.width * dpr)); H = Math.max(1, Math.round(b.height * dpr)); - canvas.width = W; canvas.height = H; R = Math.min(W, H) / 2; - draw(performance.now()); - }; - - const toPlate = e => { - const b = canvas.getBoundingClientRect(), s = Math.min(b.width, b.height) / 2; - return [(e.clientX - b.left - b.width / 2) / s, (e.clientY - b.top - b.height / 2) / s]; - }; - - const commit = (raw, born) => { - if (raw.length < 2) { const [x, y] = raw[0]; raw = [[x, y], [x + 0.002, y + 0.002]]; } - strokes.push(makeStroke(raw, (seedN++ * 2654435761) >>> 0, hue, born)); - hue = (hue + 47) % 360; - if (reduced) strokes = strokes.slice(-MAX_STROKES); - else strokes.slice(0, -MAX_STROKES).forEach(s => (s.dying ??= born)); - }; - - // ---- input - const down = e => { - if (e.button > 0) return; - canvas.setPointerCapture?.(e.pointerId); - live = [toPlate(e)]; ghost = null; lastInput = performance.now(); kick(); - }; - const move = e => { if (!live) return; const p = toPlate(e), q = live[live.length - 1]; if (Math.hypot(p[0] - q[0], p[1] - q[1]) > 0.006) live.push(p); lastInput = performance.now(); if (reduced) draw(lastInput); }; - const up = () => { if (!live) return; lastInput = performance.now(); commit(live, reduced ? GROWN : lastInput); live = null; if (reduced) draw(lastInput); }; - canvas.addEventListener('pointerdown', down); - canvas.addEventListener('pointermove', move); - canvas.addEventListener('pointerup', up); - canvas.addEventListener('pointercancel', up); - - // ---- drawing - const fold = (rot, k, mirror) => { - const c = Math.cos(rot + k * TAU / FOLDS), s = Math.sin(rot + k * TAU / FOLDS), m = mirror ? -1 : 1; - // plate units → device pixels, rotated by fold k, mirrored across the fold's axis - ctx.setTransform(c * R, s * R, -s * R * m, c * R * m, W / 2, H / 2); - }; - // Geometry is in plate units and identical in every fold, so each path is built once per frame (Path2D) - // and stroked under the 12 fold transforms (same strokes, same order, a twelfth of the path building). - const path = (into, pts, upto = 1) => { - const n = Math.min(pts.length, Math.max(2, Math.ceil(pts.length * upto))); - into.moveTo(pts[0][0], pts[0][1]); - for (let i = 1; i < n; i++) into.lineTo(pts[i][0], pts[i][1]); - return into; - }; - const ring = (s, k, off, t) => { - const amp = off * 0.16, lam = 9, lag = 0.55 * k, p = new Path2D(); - for (const side of [-1, 1]) { - for (let i = 0; i < s.pts.length; i++) { - const d = side * (off + amp * Math.sin(i / lam - lag + t * 0.0011 * (k % 2 ? 1 : -1))); - const x = s.pts[i][0] + s.nrm[i][0] * d, y = s.pts[i][1] + s.nrm[i][1] * d; - i ? p.lineTo(x, y) : p.moveTo(x, y); - } - } - return p; - }; - - // `inking` is the stroke still under the pen (the visitor's or the ghost's): trunk only, in the next hue. - const draw = (now, inking = live) => { - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, W, H); - ctx.globalCompositeOperation = 'lighter'; - ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - const rot = reduced ? 0 : now * 0.00004, px = 1 / R * dpr; // one CSS pixel in plate units - const all = inking ? [...strokes, { pts: resample(inking, 0.012), hue, born: now, branches: [] }] : strokes; - for (const s of all) { - const age = now - s.born; - const fadeOut = s.dying ? Math.max(0, 1 - (now - s.dying) / 1200) : 1; - if (fadeOut <= 0) continue; - const gSprout = ease((age - GROW_MS * 0.2) / (GROW_MS * 0.6)); - const gRing = ease((age - GROW_MS * 0.35) / (GROW_MS * 0.65)); - // rings (Ripple): wider apart outward, breathing, alternating brightness - const rings = []; - if (gRing > 0 && s.pts.length > 2) { - for (let r = 0; r < (lite ? 3 : 5); r++) { - const off = (0.022 * Math.pow(1.32, r)) * Math.min(1, gRing * 1.6 - r * 0.2); - if (off > 0) rings.push([r, ring(s, r, off, now)]); - } - } - // sprouts (fractal botany), generation by generation - const gens = []; - if (gSprout > 0) for (let gen = 1; gen <= 4; gen++) { - const p = new Path2D(); - let any = false; - for (const b of s.branches) { - if (b.gen !== gen) continue; - const g = (gSprout - b.born) / (1 - b.born) * 4 - (b.gen - 1); - if (g <= 0) continue; - path(p, b.pts, Math.min(1, g)); any = true; - } - if (any) gens.push([gen, p]); - } - const trunk = s.pts.length > 1 ? path(new Path2D(), s.pts) : null; - for (let k = 0; k < FOLDS; k++) for (const mirror of [false, true]) { - fold(rot, k, mirror); - const foldHue = (s.hue + (k * 2 + (mirror ? 1 : 0)) * (360 / (FOLDS * 2)) + (reduced ? 0 : now * 0.006)) % 360; - for (const [r, p] of rings) { - ctx.strokeStyle = `hsla(${(foldHue + r * 18) % 360}, 95%, 64%, ${(r % 2 ? 0.09 : 0.18) * fadeOut})`; - ctx.lineWidth = px * (r < 2 ? 0.8 : 1.4); - ctx.stroke(p); - } - for (const [gen, p] of gens) { - ctx.strokeStyle = `hsla(${(foldHue + gen * 14) % 360}, 100%, ${62 + gen * 4}%, ${0.38 * Math.pow(0.78, gen) * fadeOut})`; - ctx.lineWidth = px * (2.4 - gen * 0.42); - ctx.stroke(p); - } - // trunk: a soft halo, then the hot core - if (trunk) { - if (!lite) { ctx.strokeStyle = `hsla(${foldHue}, 100%, 60%, ${0.12 * fadeOut})`; ctx.lineWidth = px * 9; ctx.stroke(trunk); } - ctx.strokeStyle = `hsla(${foldHue}, 100%, 72%, ${0.6 * fadeOut})`; ctx.lineWidth = px * 2.6; ctx.stroke(trunk); - } - } - } - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.globalCompositeOperation = 'source-over'; - }; - - const frame = now => { - raf = 0; - if (!visible || document.hidden) { lastFrame = 0; return; } - // 120/144 Hz screens draw at 60 (everything here is timed, not counted in frames); nothing draws under the Atlas - if ((lastFrame && now - lastFrame < GAP - 1.5) || html.classList.contains('sy-atlas-open')) { raf = requestAnimationFrame(frame); return; } - if (lastFrame && !lite) { slow = now - lastFrame > 45 ? slow + 1 : Math.max(0, slow - 1); if (slow > 30) lite = true; } - lastFrame = now; - strokes = strokes.filter(s => !s.dying || now - s.dying < 1200); - // the ghost hand: when idle, trace a gesture point by point, then let it grow - let inking = live; - if (!live && now - lastInput > IDLE_MS) { - ghost ??= { pts: ghostPath((seedN * 7919) >>> 0), t0: now }; - const n = 1 + Math.floor((now - ghost.t0) / 22); - if (n >= ghost.pts.length) { commit(ghost.pts, now); ghost = null; lastInput = now - IDLE_MS + 2200; } - else inking = ghost.pts.slice(0, n); - } - draw(now, inking); - raf = requestAnimationFrame(frame); - }; - const kick = () => { if (!reduced && !raf && visible && !document.hidden) raf = requestAnimationFrame(frame); }; - - const io = 'IntersectionObserver' in window ? new IntersectionObserver(es => { visible = es[0].isIntersecting; kick(); }, { rootMargin: '120px' }) : null; - io?.observe(canvas); - const onVis = () => kick(); - document.addEventListener('visibilitychange', onVis); - const ro = 'ResizeObserver' in window ? new ResizeObserver(size) : null; - ro?.observe(canvas); - - // seed the plate so it is never empty: two gestures, already grown - commit(ghostPath(1000), GROWN); commit(ghostPath(1031), GROWN); - lastInput = performance.now() - IDLE_MS + 1500; - size(); - kick(); - - return { - destroy() { - cancelAnimationFrame(raf); io?.disconnect(); ro?.disconnect(); - document.removeEventListener('visibilitychange', onVis); - canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); - canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up); - }, - }; -} diff --git a/src/site/slot.js b/src/site/slot.js deleted file mode 100644 index d17b8b4..0000000 --- a/src/site/slot.js +++ /dev/null @@ -1,135 +0,0 @@ -/* The hero headline as four slot reels: Creative systems. / Reliable agents. Each reel is a vertical strip of words - (Creative / Generative / Reliable, humans / agents / systems) that spins once and stops on its word, staggered left - to right and top to bottom. Mid-spin the reels pass real combinations ("Generative agents", "Reliable humans"): - the same parts, recombined, settling on the two the lab ships. - - Everything that moves during the spin runs on the compositor, so a busy main thread (the hero field, a slow - phone) cannot make it stutter: each strip's travel is one transform animation sampled from a velocity curve - (a smooth start, a steady glide, a long ease-out, a small overshoot that settles back), and its motion blur is a - pre-painted, vertically streaked copy of the words whose opacity follows the speed. Nothing changes layout while - the reels spin: each reel is laid out at its final word's width and only paints wider while a longer word - passes, and a noun waiting beside a wide adjective slides (transform) into place as that adjective lands. Once - settled, the nouns hold a static spectrum and take a spark every few seconds; between sparks nothing runs. - - Markup (src/App.jsx Slot): .home-slot > .home-slot__line > .home-slot__reel > .home-slot__strip > - (.home-slot__face, .home-slot__ghost) > .home-slot__w, the face's last word marked .is-final. Without JavaScript - or under reduced motion only the final words show, as plain text. mountSlot(h1) -> { destroy() } */ - -const STOPS = [1050, 1400, 1750, 2100]; // ms from the start: when each reel lands -const LEAD = 45; // ms between reel starts, so the four never move as one block -const GLINT_MS = 7000, SWEEP_MS = 1500; // a spark across the nouns every 7s, taking 1.5s to cross both - -// Travel along a strip for u in [0, 1]: velocity rises smoothly over the first 16%, glides, then decays as -// (1 - x)^2 through the last half of the travel, which ends at 92% of the time a little past the stop; the last -// 8% settles back. Sampled into linear keyframes, so the curve is the same in every browser and runs off-thread. -const ACC = .16, DEC = .5, END = .92, OVER = .05; // OVER in em -const vel = s => s < ACC ? (1 - Math.cos(Math.PI * s / ACC)) / 2 : s < 1 - DEC ? 1 : (1 - (s - (1 - DEC)) / DEC) ** 2; -const travel = (() => { - const N = 600, acc = [0]; let sum = 0; - for (let i = 1; i <= N; i++) { sum += vel((i - .5) / N) / N; acc.push(sum); } - return s => { const x = Math.min(N, Math.max(0, s * N)), i = Math.min(N - 1, Math.floor(x)); return (acc[i] + (acc[i + 1] - acc[i]) * (x - i)) / sum; }; -})(); -const pos = (u, D) => u <= END ? (D + OVER) * travel(u / END) : D + OVER * (1 + Math.cos(Math.PI * (u - END) / (1 - END))) / 2; -const speed = u => u <= END ? vel(u / END) : 0; - -function keyframes(D, ms) { - const n = Math.max(24, Math.round(ms / 14)), move = [], face = [], ghost = []; - for (let i = 0; i <= n; i++) { - const offset = i / n, v = speed(offset); - move.push({ offset, transform: `translate3d(0, ${(-pos(offset, D)).toFixed(4)}em, 0)` }); - face.push({ offset, opacity: +(1 - .6 * v).toFixed(3) }); - ghost.push({ offset, opacity: +(.9 * v).toFixed(3) }); - } - return { move, face, ghost }; -} - -export function mountSlot(h1) { - const reduced = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches); - if (!h1) return { destroy() {} }; - if (reduced || !h1.animate) { h1.classList.add('is-static'); return { destroy() {} }; } - const reels = [...h1.querySelectorAll('.home-slot__reel')].map(r => { - const strip = r.querySelector('.home-slot__strip'), face = r.querySelector('.home-slot__face'), ghost = r.querySelector('.home-slot__ghost'); - const words = face ? [...face.children] : []; - return { r, strip, face, ghost, words, final: words[words.length - 1], D: 0, max: 0, fin: 0 }; - }); - if (reels.length !== 4 || reels.some(d => !d.ghost || !d.final)) { h1.classList.add('is-static'); return { destroy() {} }; } - - h1.classList.add('is-live'); - const anims = [], timers = [], at = (ms, fn) => timers.push(setTimeout(fn, ms)); - let raf = 0, dead = false, settled = false, sweepAt = 0; - const nouns = [reels[1].final, reels[3].final]; - - // each reel's box is as wide as its widest word, and a negative margin (--trim) hands the difference back to the - // line, so the line is laid out once, at its final widths; after the stop the box is just its word - const size = () => { - for (const d of reels) { - d.fin = d.final.offsetWidth; d.max = settled ? d.fin : Math.max(...d.words.map(w => w.offsetWidth)); - d.r.style.width = d.max + 'px'; d.r.style.setProperty('--trim', (d.fin - d.max) + 'px'); - } - }; - size(); - const ro = 'ResizeObserver' in window ? new ResizeObserver(() => { if (settled) size(); }) : null; - ro?.observe(h1); - - // the spark: a highlight crossing both nouns (line 2 a beat after line 1); rAF runs only while it crosses - const resume = () => { if (!document.hidden && !dead) { document.removeEventListener('visibilitychange', resume); raf = requestAnimationFrame(sweep); } }; - const sweep = now => { - raf = 0; if (dead) return; - if (!sweepAt) sweepAt = now; - const t = now - sweepAt; - nouns.forEach((w, i) => { - const W = w.offsetWidth || 1, p = Math.min(1, Math.max(0, (t - i * 300) / (SWEEP_MS - 300))); - w.style.setProperty('--ww', W + 'px'); - w.style.backgroundPosition = `${(-W + 2 * W * p).toFixed(1)}px 0, 0 0`; - }); - if (t < SWEEP_MS) { raf = requestAnimationFrame(sweep); return; } - sweepAt = 0; - at(GLINT_MS - SWEEP_MS, () => { if (document.hidden) document.addEventListener('visibilitychange', resume); else resume(); }); - }; - - const start = () => { - if (dead) return; - size(); - const em = parseFloat(getComputedStyle(h1).fontSize) || 1; - for (const d of reels) d.D = d.final.offsetTop / em; // the strip's travel, in em (words are spaced in CSS) - reels.forEach((d, i) => { - const delay = i * LEAD, ms = STOPS[i] - delay, k = keyframes(d.D, ms), opt = { duration: ms, delay, fill: 'both', easing: 'linear' }; - anims.push(d.strip.animate(k.move, opt), d.face.animate(k.face, opt), d.ghost.animate(k.ghost, opt)); - at(STOPS[i], () => d.r.classList.add('is-stopped')); - }); - // a noun beside an adjective narrower than that adjective's widest word waits to its right, then slides into - // place over the adjective's last 560ms - [[0, 1], [2, 3]].forEach(([a, n]) => { - const gap = reels[a].max - reels[a].fin; - if (gap < 1) return; - anims.push(reels[n].r.animate([ - { transform: `translate3d(${gap}px, 0, 0)` }, - { transform: `translate3d(${gap}px, 0, 0)`, offset: (STOPS[a] - 560) / STOPS[a], easing: 'cubic-bezier(.45, 0, .2, 1)' }, - { transform: 'translate3d(0, 0, 0)' }, - ], { duration: STOPS[a], fill: 'both' })); - }); - // hand over to plain styles once the last reel has settled; the spark follows shortly after - at(STOPS[3] + 80, () => { - if (dead) return; - settled = true; - for (const d of reels) d.strip.style.transform = `translate3d(0, ${-d.D}em, 0)`; - anims.forEach(a => a.cancel()); anims.length = 0; - size(); h1.classList.add('is-rest'); - at(700, resume); - }); - }; - // two frames after the fonts are in, so the first frame of the spin is never a style or layout frame - const go = () => at(200, () => requestAnimationFrame(() => requestAnimationFrame(start))); - const io = 'IntersectionObserver' in window ? new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); (document.fonts?.ready || Promise.resolve()).then(go); } }) : null; - if (io) io.observe(h1); else go(); - - return { - destroy() { - dead = true; timers.forEach(clearTimeout); if (raf) cancelAnimationFrame(raf); - anims.forEach(a => a.cancel()); - io?.disconnect(); ro?.disconnect(); document.removeEventListener('visibilitychange', resume); - h1.classList.remove('is-live', 'is-rest'); - for (const d of reels) { d.r.classList.remove('is-stopped'); d.r.style.width = ''; d.r.style.removeProperty('--trim'); d.strip.style.transform = ''; } - }, - }; -} diff --git a/src/site/strike.js b/src/site/strike.js deleted file mode 100644 index c92c983..0000000 --- a/src/site/strike.js +++ /dev/null @@ -1,275 +0,0 @@ -/* The runtime strike (homepage, RISE Composer card): Compose -> Admit -> Experience as light. - Canvas 2D, additive ('lighter'), seeded so every visit draws the same picture, played once and then frozen on its - last frame; nothing runs after that. The scene is a pure function of time, so resizing redraws the same instant. - - Lightning: midpoint-displacement fractals subdivided below 2 px, with recursive branches down to hair-thin - filaments. Each bolt propagates as a stepped leader, flashes white on the return stroke, re-strikes twice and - settles to a residual glow. Strokes are drawn in passes (wide glow, mid band, hot core) with chromatic fringes. - Burst: core flash, halo, a dispersed chromatic ring, eight diffraction spikes split into three colour channels, - a field of hairline rays, and a particle splash that decelerates under drag (fast particles run blue, slow ones - magenta and amber, as in dispersion) and freezes mid-flight as fine streaks. The splash leans left, away from the - stage text; anything aimed right stays short. - - mountStrike(canvas) -> { layout(pts, w, h, { burst }), play(), still(), destroy() } - pts: the three node centres [x, y, radius] in the flow's own coordinates; w, h: the flow's size; - burst: the splash's scale (1 by default; smaller on narrow layouts, so it stays inside its card). */ - -const PAD_X = 190, PAD_Y = 150; // the canvas overhangs the flow so the splash has room -// The rhythm: (beat) 1 -> 2 (beat) 2 -> 3 (tiny beat) splash. -// 0-750 node 1 charges | 750-1200 bolt 1 | 1200-2100 node 2 holds | 2100-2550 bolt 2 | 2550-2850 node 3 swells | 2850 burst -const LEADER = 450; // ms for a stepped leader to reach the next node -const BURST = 1500; // ms for the burst to reach its frozen extent -const T = { bolt1: 750, bolt2: 2100, burst: 2850, end: 2850 + BURST + 100 }; - -const rng32 = seed => () => { seed |= 0; seed = seed + 0x6d2b79f5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; -const clamp01 = x => x < 0 ? 0 : x > 1 ? 1 : x; -const easeOut = x => 1 - Math.pow(1 - clamp01(x), 3); -const STOPS = [[0, [144, 216, 240]], [0.3, [72, 144, 240]], [0.55, [154, 107, 255]], [0.8, [255, 88, 214]], [1, [255, 181, 74]]]; -function spectral(u) { - u = clamp01(u); - for (let i = 1; i < STOPS.length; i++) if (u <= STOPS[i][0]) { - const [u0, a] = STOPS[i - 1], [u1, b] = STOPS[i], k = (u - u0) / (u1 - u0); - return a.map((v, j) => Math.round(v + (b[j] - v) * k)); - } - return STOPS[STOPS.length - 1][1]; -} -const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a < 0 ? 0 : a > 1 ? 1 : a.toFixed(3)})`; -const whiten = (c, k) => c.map(v => Math.round(v + (255 - v) * k)); -function hsl(h, s, l) { // degrees, 0..1, 0..1 -> [r,g,b] - h = ((h % 360) + 360) % 360 / 360; const q = l < .5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; - const f = t => { t = (t + 1) % 1; return Math.round(255 * (t < 1 / 6 ? p + (q - p) * 6 * t : t < .5 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p)); }; - return [f(h + 1 / 3), f(h), f(h - 1 / 3)]; -} - -/* ---------- fractal lightning ---------- */ -function subdivide(a, b, rng, rough, minLen) { - let pts = [a, b]; - for (;;) { - const next = [pts[0]]; let split = false; - for (let i = 1; i < pts.length; i++) { - const p = pts[i - 1], q = pts[i], dx = q[0] - p[0], dy = q[1] - p[1], len = Math.hypot(dx, dy); - if (len > minLen) { - split = true; const off = (rng() - .5) * len * rough; - next.push([(p[0] + q[0]) / 2 - dy / len * off, (p[1] + q[1]) / 2 + dx / len * off]); - } - next.push(q); - } - pts = next; if (!split) return pts; - } -} -const arc = pts => { const s = [0]; for (let i = 1; i < pts.length; i++) s.push(s[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])); const L = s[s.length - 1] || 1; return s.map(v => v / L); }; - -// one bolt: a main channel and recursive branches; every line knows where on the main channel it starts (s0) -function bolt(a, b, seed, railX) { - const rng = rng32(seed), lines = []; - // keep the channel off the stage text: rightward excursions are squashed, leftward ones run free - const keep = p => { const dx = p[0] - railX; return [railX + (dx > 0 ? Math.min(dx * .5, 14) : Math.max(dx, -60)), p[1]]; }; - const main = subdivide(a, b, rng, .5, 1.8).map(keep); - const mainLen = Math.hypot(b[0] - a[0], b[1] - a[1]); - lines.push({ pts: main, s: arc(main), s0: 0, span: 1, w: 1, a: 1 }); - const grow = (pts, s0base, spanBase, depth, len, w, alpha) => { - for (let k = 3; k < pts.length - 3; k++) { - if (rng() > [0, .055, .045, .03][depth]) continue; - const p = pts[k], q = pts[k + 2], dir = Math.atan2(q[1] - p[1], q[0] - p[0]); - const left = rng() < .82 ? 1 : -1; // most branches fork towards the open gutter on the left - const ang = dir + left * (.35 + rng() * .75), L = len * (.22 + rng() * .4); - let e = [p[0] + Math.cos(ang) * L, p[1] + Math.sin(ang) * L]; - if (e[0] > railX + 18) e = [railX + 18 - (e[0] - railX - 18) * .3, e[1]]; - const bp = subdivide(p, e, rng, .58, 1.4).map(keep); - const s0 = s0base + spanBase * (k / pts.length); - lines.push({ pts: bp, s: arc(bp), s0, span: L / mainLen, w: w, a: alpha, depth }); - if (depth < 3) grow(bp, s0, L / mainLen, depth + 1, L, w * .6, alpha * .72); - } - }; - grow(main, 0, 1, 1, mainLen, .55, .8); - return lines; -} - -function strokeLine(ctx, pts, s, upto) { - ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); - for (let i = 1; i < pts.length; i++) { - if (s[i] > upto) { const k = (upto - s[i - 1]) / ((s[i] - s[i - 1]) || 1); ctx.lineTo(pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * k, pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * k); break; } - ctx.lineTo(pts[i][0], pts[i][1]); - } - ctx.stroke(); -} - -// leader position (0..1) and brightness of a bolt that starts at t0 -function boltState(t, t0) { - if (t < t0) return null; - const dt = t - t0; - if (dt < LEADER) return { leader: easeOut(dt / LEADER) * 1.02, bright: .5 }; - const r = dt - LEADER; - let bright = .4 + 1.5 * Math.exp(-r / 130); - if (r > 70 && r < 100) bright += .7; // re-strikes - if (r > 180 && r < 205) bright += .45; - return { leader: 1.02, bright }; -} - -function drawBolt(ctx, lines, st, colorAt) { - const { leader, bright } = st; - for (const ln of lines) { - const vis = ln.depth ? clamp01((leader - ln.s0) / (ln.span * .6 + .04)) : clamp01(leader); - if (vis <= 0) continue; - const c = colorAt(ln.pts[0][1]), b = bright * ln.a, hot = whiten(c, .55 + .35 * clamp01(bright - 1)); - if (!ln.depth || ln.depth === 1) { ctx.strokeStyle = rgba(c, .05 * b); ctx.lineWidth = 9 * ln.w; strokeLine(ctx, ln.pts, ln.s, vis); } - ctx.strokeStyle = rgba(c, .16 * b); ctx.lineWidth = 2.8 * ln.w; strokeLine(ctx, ln.pts, ln.s, vis); - // chromatic fringes either side of the core - ctx.save(); ctx.translate(-.8, 0); ctx.strokeStyle = rgba([80, 200, 255], .28 * b); ctx.lineWidth = .9 * ln.w + .25; strokeLine(ctx, ln.pts, ln.s, vis); ctx.restore(); - ctx.save(); ctx.translate(.8, 0); ctx.strokeStyle = rgba([255, 70, 200], .28 * b); ctx.lineWidth = .9 * ln.w + .25; strokeLine(ctx, ln.pts, ln.s, vis); ctx.restore(); - ctx.strokeStyle = rgba(hot, .9 * Math.min(1.2, b)); ctx.lineWidth = Math.max(.35, 1.05 * ln.w); strokeLine(ctx, ln.pts, ln.s, vis); - } -} - -/* ---------- glows, coronas ---------- */ -function glow(ctx, x, y, r, c, a) { - if (a <= 0 || r <= 0) return; - const g = ctx.createRadialGradient(x, y, 0, x, y, r); - g.addColorStop(0, rgba(whiten(c, .8), a)); g.addColorStop(.25, rgba(c, a * .45)); g.addColorStop(1, rgba(c, 0)); - ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); -} -function corona(x, y, r, seed) { // short fractal sparks around a charging node (r: the node's radius) - const rng = rng32(seed), out = []; - for (let i = 0; i < 9; i++) { - const a = Math.PI * (.55 + rng() * .95) + (rng() < .25 ? Math.PI : 0), L = 8 + rng() * 15, r0 = r + 2; - const p = [x + Math.cos(a) * r0, y + Math.sin(a) * r0], q = [x + Math.cos(a) * (r0 + L), y + Math.sin(a) * (r0 + L)]; - const pts = subdivide(p, q, rng, .7, 1.2); out.push({ pts, s: arc(pts), ph: rng() * 6.28 }); - } - return out; -} - -/* ---------- the burst ---------- */ -function burstModel(seed) { - const rng = rng32(seed), right = a => Math.cos(a) > .15; - const rays = [], parts = [], spikes = []; - for (let i = 0; i < 260; i++) { - const a = rng() * Math.PI * 2, L = 16 + Math.pow(rng(), 2.2) * 120; - const h = (a * 180 / Math.PI) * 1.4 + rng() * 40; - rays.push({ a, L: right(a) ? L * .4 : L, key: `${Math.floor(((h % 360) + 360) % 360 / 15)}|${rng() < .5 ? 0 : 1}` }); - } - for (let i = 0; i < 1100; i++) { - let a = rng() * Math.PI * 2; if (right(a) && rng() < .62) a = Math.PI - a; // lean left - const v = 50 + Math.pow(rng(), 2.6) * 330, k = 2.6 + rng() * 1.6; - // dispersion: hue follows speed (fast = blue/cyan, slow = magenta/amber) with a spread, so the splash runs the spectrum - const h = 185 + (.55 * rng() + .45 * (1 - v / 380)) * 215; - const hot = rng() < .12, wide = rng() < .45; - parts.push({ a, v, k, sh: right(a) ? .38 : 1, wide, key: hot ? `hot|${wide ? 1 : 0}` : `${Math.floor(((h % 360) + 360) % 360 / 15)}|${rng() < .5 ? 0 : 1}|${wide ? 1 : 0}` }); - } - const base = .21; - for (let i = 0; i < 8; i++) { - const a = base + i * Math.PI / 4, primary = i % 2 === 0, L = (primary ? 112 : 62) * (right(a) ? .42 : 1); - spikes.push({ a, L, w: primary ? 1.15 : .7 }); - } - return { rays, parts, spikes }; -} - -function drawBurst(ctx, x, y, tau, m) { - if (tau < 0) return; - const u = clamp01(tau / BURST), e = easeOut(u), ts = tau / 1000; - // core flash, then a held core - const flash = Math.exp(-tau / 160); - glow(ctx, x, y, 90 * (.35 + .65 * e), [154, 107, 255], .16 + .2 * flash); - glow(ctx, x, y, 46 * (.4 + .6 * e), [255, 88, 214], .22 + .3 * flash); - glow(ctx, x, y, 16 + 14 * flash, [255, 214, 160], .75 + .25 * flash); - // chromatic ring (dispersed halo) - if (ctx.createConicGradient) { - const g = ctx.createConicGradient(0, x, y); - [[0, '#90d8f0'], [.17, '#4890f0'], [.34, '#9a6bff'], [.5, '#ff58d6'], [.67, '#ffb54a'], [.84, '#6ff5a8'], [1, '#90d8f0']].forEach(([o, c]) => g.addColorStop(o, c)); - ctx.save(); ctx.globalAlpha = .38 * (.4 + .6 * e); ctx.strokeStyle = g; ctx.lineWidth = 1.1; - ctx.beginPath(); ctx.arc(x, y, 12 + 16 * e, 0, Math.PI * 2); ctx.stroke(); - ctx.globalAlpha = .16 * e; ctx.lineWidth = .7; ctx.beginPath(); ctx.arc(x, y, 24 + 22 * e, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); - } - // hairline ray field: 24 hue buckets x 2 strengths; each ray is a brighter inner half and a faint outer half - const rb = new Map(); - for (const r of m.rays) { - const L = r.L * e; if (L < 1) continue; - let g = rb.get(r.key); if (!g) rb.set(r.key, g = { inner: new Path2D(), outer: new Path2D() }); - const ca = Math.cos(r.a), sa = Math.sin(r.a); - g.inner.moveTo(x, y); g.inner.lineTo(x + ca * L * .5, y + sa * L * .5); - g.outer.moveTo(x + ca * L * .5, y + sa * L * .5); g.outer.lineTo(x + ca * L, y + sa * L); - } - ctx.lineWidth = .5; - for (const [key, g] of rb) { - const [hb, strong] = key.split('|').map(Number), c = hsl(hb * 15 + 7, 1, .72), al = strong ? .2 : .09; - ctx.strokeStyle = rgba(c, al); ctx.stroke(g.inner); ctx.strokeStyle = rgba(c, al * .35); ctx.stroke(g.outer); - } - // diffraction spikes, each split into three dispersed channels - const chans = [[[255, 120, 90], 1, .006], [[120, 255, 170], .93, 0], [[110, 150, 255], .86, -.006]]; - for (const s of m.spikes) for (const [c, lk, da] of chans) { - const L = s.L * lk * e, a = s.a + da, ex = x + Math.cos(a) * L, ey = y + Math.sin(a) * L; - const g = ctx.createLinearGradient(x, y, ex, ey); - g.addColorStop(0, rgba([255, 255, 255], .55)); g.addColorStop(.12, rgba(c, .32)); g.addColorStop(1, rgba(c, 0)); - ctx.strokeStyle = g; ctx.lineWidth = s.w; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(ex, ey); ctx.stroke(); - } - // the splash: drag-decelerated particles as streaks (faint tail, bright head), stroked per colour bucket - const fade = .55 + .45 * Math.exp(-ts * 1.6), pb = new Map(); - for (const p of m.parts) { - const d = t => p.v / p.k * (1 - Math.exp(-p.k * t)) * p.sh; - const d1 = d(ts); if (d1 < 2) continue; - const d0 = d(ts * .3) * .7, dm = d0 + (d1 - d0) * .6, ca = Math.cos(p.a), sa = Math.sin(p.a); - let g = pb.get(p.key); if (!g) pb.set(p.key, g = { tail: new Path2D(), head: new Path2D(), dots: new Path2D() }); - g.tail.moveTo(x + ca * d0, y + sa * d0); g.tail.lineTo(x + ca * dm, y + sa * dm); - g.head.moveTo(x + ca * dm, y + sa * dm); g.head.lineTo(x + ca * d1, y + sa * d1); - if (p.wide) { g.dots.moveTo(x + ca * d1 + .7, y + sa * d1); g.dots.arc(x + ca * d1, y + sa * d1, .7, 0, Math.PI * 2); } - } - for (const [key, g] of pb) { - const k = key.split('|'), hot = k[0] === 'hot', wide = k[k.length - 1] === '1', strong = hot || k[1] === '1'; - const c = hot ? [255, 244, 230] : hsl(+k[0] * 15 + 7, 1, .66), al = (strong ? .9 : .5) * fade; - ctx.lineWidth = wide ? 1.15 : .6; - ctx.strokeStyle = rgba(c, al * .3); ctx.stroke(g.tail); - ctx.strokeStyle = rgba(c, al); ctx.stroke(g.head); - if (wide) { ctx.fillStyle = rgba(whiten(c, .6), al * .75); ctx.fill(g.dots); } - } -} - -export function mountStrike(canvas) { - const ctx = canvas.getContext && canvas.getContext('2d'); - if (!ctx) return { layout() {}, play() {}, still() {}, destroy() {} }; - let pts = null, W = 0, H = 0, dpr = 1, bs = 1, bolts = [], coronas = [], model = burstModel(20261008), t = -1, raf = 0, t0 = 0, dead = false; - const colorAt = y => { const y0 = pts[0][1], y1 = pts[2][1]; return spectral((y - y0) / ((y1 - y0) || 1)); }; - - function scene(time) { - ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); - if (!pts) return; - ctx.setTransform(dpr, 0, 0, dpr, PAD_X * dpr, PAD_Y * dpr); - ctx.strokeStyle = 'rgba(170,180,255,.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); ctx.lineTo(pts[2][0], pts[2][1]); ctx.stroke(); // the rail - if (time < 0) return; - ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - const [n1, n2, n3] = pts; - // node 1 charges; node 2 flashes when the first bolt lands; both keep a residual glow - const c1 = clamp01(time / T.bolt1), land1 = time - (T.bolt1 + LEADER), land2 = time - (T.bolt2 + LEADER); - glow(ctx, n1[0], n1[1], 30, spectral(0), .35 * c1 + (time < T.bolt1 + LEADER ? .25 * c1 * (1 + Math.sin(time / 23)) * .5 : 0)); - for (const s of coronas[0]) { const a = (time < T.bolt1 + 200 ? c1 * (.4 + .6 * Math.abs(Math.sin(time / 37 + s.ph))) : .22) * .8; ctx.strokeStyle = rgba(whiten(spectral(0), .4), a); ctx.lineWidth = .55; strokeLine(ctx, s.pts, s.s, 1); } - if (land1 > 0) { glow(ctx, n2[0], n2[1], 34, spectral(.55), .3 + .7 * Math.exp(-land1 / 200)); for (const s of coronas[1]) { ctx.strokeStyle = rgba(whiten(spectral(.55), .4), .2 + .5 * Math.exp(-land1 / 260)); ctx.lineWidth = .55; strokeLine(ctx, s.pts, s.s, 1); } } - const b1 = boltState(time, T.bolt1), b2 = boltState(time, T.bolt2); - if (b1) drawBolt(ctx, bolts[0], b1, colorAt); - if (b2) drawBolt(ctx, bolts[1], b2, colorAt); - // the tiny beat: node 3 swells with the arrived charge, then bursts - if (land2 > 0 && time < T.burst + 160) { const k = clamp01(land2 / (T.burst - T.bolt2 - LEADER)); glow(ctx, n3[0], n3[1], 18 + 22 * k, [255, 181, 74], (.25 + .55 * k) * (.85 + .15 * Math.sin(time / 19))); } - if (time >= T.burst - 16) { ctx.save(); ctx.translate(n3[0], n3[1]); ctx.scale(bs, bs); drawBurst(ctx, 0, 0, time - T.burst, model); ctx.restore(); } - ctx.globalCompositeOperation = 'source-over'; - } - - function frame(now) { - raf = 0; if (dead) return; - t = Math.min(T.end, now - t0); - scene(t); - if (t < T.end) raf = requestAnimationFrame(frame); - } - - return { - layout(p, w, h, o = {}) { - pts = p; W = w; H = h; bs = o.burst || 1; dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.style.left = -PAD_X + 'px'; canvas.style.top = -PAD_Y + 'px'; - canvas.style.width = W + 2 * PAD_X + 'px'; canvas.style.height = H + 2 * PAD_Y + 'px'; - canvas.width = Math.round((W + 2 * PAD_X) * dpr); canvas.height = Math.round((H + 2 * PAD_Y) * dpr); - const railX = p[0][0]; - bolts = [bolt([p[0][0], p[0][1] + p[0][2]], [p[1][0], p[1][1] - p[1][2]], 101, railX), bolt([p[1][0], p[1][1] + p[1][2]], [p[2][0], p[2][1] - p[2][2]], 202, railX)]; - coronas = [corona(p[0][0], p[0][1], p[0][2], 7), corona(p[1][0], p[1][1], p[1][2], 13)]; - scene(t); - }, - play() { if (raf || t >= T.end) return; t0 = performance.now() - Math.max(0, t); raf = requestAnimationFrame(frame); }, - still() { if (raf) cancelAnimationFrame(raf); raf = 0; t = T.end; scene(t); }, - destroy() { dead = true; if (raf) cancelAnimationFrame(raf); }, - }; -} diff --git a/src/sketch-gallery/index.js b/src/sketch-gallery/index.js deleted file mode 100644 index 1bbd8e6..0000000 --- a/src/sketch-gallery/index.js +++ /dev/null @@ -1,19 +0,0 @@ -// Showcase timelapses for the homepage (src/App.jsx SketchGallery), exported by RISE Sketch's own Share -// timelapse (720 px, H.264). Each `remix` is that drawing's link: opening it replays the drawing in RISE -// Sketch, ready to keep drawing. Regenerate with scripts/showcase.mjs in the RISE-Sketch repo. - -import v1 from './sketch-1.mp4'; -import p1 from './sketch-1.webp'; -import v2 from './sketch-2.mp4'; -import p2 from './sketch-2.webp'; -import v3 from './sketch-3.mp4'; -import p3 from './sketch-3.webp'; - -export const gallery = [ - { title: "Spectral Bloom", caption: "Sprout and Caustic \u00b7 6-fold kaleidoscope \u00b7 Spectral ink \u00b7 Night", video: v1, poster: p1, - remix: "https://sketch.syberlabs.io/#r=H4sIAAAAAAAAE-2cCVhUVd_A_8AwDMOwI26o4xUV5TKee89dcRvNhRSLXFJzA4EBFARZXNNGM1Oj4jXrc-Y1JffPTCmXtDRxTXPGNJe0LDH3wiRB2eH77gyKKMgr8JYZh-eZeznnnnPuPec357-duTYzCV18YlxYMhFIJMYkRRIkMSUyMSkmfhIRSJNEWEJCRUFA0sTI5PBoLdJQGkSQSiIuMjmMCJxJJMckx0YSgcSwSZazCIIkwhMjw5IjI4hAihcpRuRpmhF5niRSEiKq5jMix4gkEREfPiTSks8iJCKOEwSSCI9PmZQcmUgEMjRJxEya-Jz1_ySpz6jEsITomORIIhBJZRExUfGW0_hpMRHWzPjw6ETrWVx8UpLlJDE-yZqTlBAZnpwYFksEYpIIT0lKjo8jAtEskohKjE-ZFEEEEpNioqKTpQcJi4tMtDxl-DRL1fDp1hbCw6RnpqRGk6W6s0glkZScGD8xMokIHKWcScRIzaC4KQhz08MxQoiudlxYgaJIIiJySky4NIYJkZMIkohPjImKmUQEjkJkAEZjSGLGA12RRJJlqDiaESiaplnpLmNjxks3GSuNggYxJBEdI50JJBEVFhcXZqmui5UmmSKJKYOk-kgjksSEmGTLECMNJgld-CCpV6xhZ5EVDyM1OklqmxifmJIUTZBEUsyMSCJQnCXNT2x8onRBzKSJRGDloJLERMttRkRbD8GWQ2x4NBE4KSU2dhZpAU6qaD0SSQmJ8SnSaE-xIDc-TJomWuohNj5JulVdWGxSJEkkhkXESJNW8W8Sss5IinVUEiOTUuIirX2QRNL0uHun03T3M5MTLXiIJJEUFpcQG2nlIiE-PjaJCCSIWWSVieOp8ckIIfxPnDjuaZm5UUjDoiqJIpFG4DhEswzCvMAwWODIgGryqqlJsxpRQDxHUxRmMaZYnqQYjVglcWPqSgrzTySFop8aVAKQhqk6l8KjrAjVsGLJe6Tqw7BwDMk8DEudWWH_kawITw8rFElpaJrhGI4XRcRzIk1HBlAcGVBTNok1HI9FBmGRpzHiOS4ygGJJDtWZAe6fyADNPD0MPCIhmGoWB-bRNcRy2aNVH5EumGRYTVURxNSZFv6fSAtGTw0tSMPWoGZUp3lUQwNPUmwdZp8On4gQEqubfUwhiq559jEiA1hKI3IUS_EYY1FgMM_WgAPGiMUchQTmUR44VqB4zPGYo0WeQ8J9PBDPsxzP8Twj8gLHVNKiQRyNKB5TPC1QmBHpavARsKVVxCKBYSgR_xdpov4u5gj1GBAYnIIQCmsEof4gPKvmjWhVTxmEEUOxImbIimmnES8glmYxrmEFqh288Y3g1R-8Z9daCkBWTYcVOZEVGMSKiMcMQ5EUwhqRxggLFMWwSOTpOiMY3ohgAyD4tzbCAljxIRO8EjCG43iRE_iatKzaAavWed0I2JMB9gxbeMLDAvaefKWwIPAMg5m6y9fIRvbqz97f2F5kH2HrnkjFDMOxjCQ5OUpAYp2WtxmSCyGqWsQYSUrXiFgAQ2kQQzOUyGDMiphnyQCKojU8hymaZlnBkleTj0GU2OCpaogTRIETRIx5XuA5WsD3iaMYUaQQg7BAY4HFDxBH0RRiWRFTgigIDIWrIY4XEOZZgWZ4gRYYifSnhLjwsJSk5JhwK3LUn21W0o9Bg4uLRghFN6Lx17gb_gwwGtbKpChBQ_GciAWGo1mRpziSltZ6nhcwQ_GIFliGr2GNqh3EmEYQ_yJ3x59CYgNbnRQramhKxCwviBwlIhGTFMYaTtpGwoo8y0kO7jqzOKGRxb_KA_LnwPjk9qdAa5DACFgQOJ4SeJYiaZrVsDRHMyxFWbLqjNvERtz-Km_Hn7X2Nag1ijkNEmkkiqwomQccTdKI0XCCwHAUjxDF1rQ9oXYUYxtR_Kv8In-WQvhExinPayjMsJSIaFEySClSpDSiFE8QKYZiOZaqC2u8jkYITaqWNWluHsMaJWgwonnMWDCjkaihERIESmQFhHihBsooUdpwSiEJ44cwoyiaYVie5VlGcl7z3H3MaIoTecRQiBd5yRlTSRnPYp5iWB4znBRrqQYymqNZGnOiKEVfuP8iY_RTZnLUbIvixwAhTpyGEIpvBKLBgHiKVP-GNUJFpMGSisYyLE8zDEMKgoZnRAEJiKVYzFJCDQtS7fwlNPLXYPw9Vep-A-8PRYKGo3nMiwKmJQQxSYu8RmQRJ0o7dtgagwG1Izi5EcGGQ_BpMgHqsO2U03CIZaS4pjUaQDKUoGEQxXNYpDhEsTXtWK8ds8RGzBoMs6dJvW9oS5NisYanRIrneCRJW8yQmBY1ktrPsgLHMAKqye1RO4RJjRA2GIRPUTTqiW1Miqc1iKZEikKCRZ6SFI00DEshHouIY3i6Lgtd2AQJ55TqGGMojuIfw5jk2aM4TvKusIIUWhfIAKrG_dQsxdM8jZD4KFpY2pqCROmLgxAnVpLFCxzHchRPUzQrbcS-jxZNs1IshUE8K1K8WJ0Dg-M5zNA0xTAUFlnqv7kZ--mDq2bzknkMC5FTKYTQlEYWniFHQwPvoLVOM4eQNJqcSJMB1L1dtRTPUlTNO3xqB29qI3jPlEOjge1JhkUajDElspyAGBazPFmxAUigGUHgWI5BmGa5GiNMtRM4rZHAZ8yl8eT25H3MkDSkkllJ0jX-iLF2pKY3IvWMuSga1na8P9WIk2wvkeNJLDSURJ3RCN-z5bh4UpuxQkAyvPRrVkagEY8RhziSZuuPWBSNEaKoahETeF587M9uqZp3YYsUxTMC9-jvbimWFhAjIMSySApqV7ogMGYZWvoRFyUppcKDRPEUphgGUYwoMAxbHVCYoimMKIblRIGnn5KXvvy1RiL7uGmfLCJEVfu6nsZpr_crY_5-JiFFa0RR-vUakn4IwbG8QPIPKe81hRprx6zalws1Ylb_9838HQ3Ah0kTaJKmH7qsJhdo7ahV-3aiRtTq_7qap9XQozQCliIXlChwFIsFbHlfDa7J1KudoGrfWdRIUP1fdvN3tOsCHpGLvLRaVb2upp-B1M5ate9GamSt_q_KeSrNuGp1rJrcnFXgUY6ZpYQdCqhIer0e9FoArV6droXMUHWoNkubpU7PSHdTZ4aoM7R-S6P9MrSHD_vpr-ek-ZWcV18vUW30BcjSZoSOyAka4QWrY91kfjvUO85vJSF9dMRZ1bxuhxecDToU0X1Kmnf7xKCFK9_acMrfs9npC5_MG3u2o2zVFgCjAUeZDRCl0zGGKNAZDVEGs1FnBqwzYqOBwdgMBgAwms0mM5jNYIgCsw6MOrPlgp8_cH553hc7bjX3778kMOp5zyJRviq4z7coY4nftjcutZo58GxsyHFq19YxRV_Lb35uOBS78_Q196B-r27InUBAM6hPQvWq_TdO6b4y7cXYIbsNScOmRbTbFx4X6K1o8dKWvo526F8-2T-c-eHitSunt1xyGzp_9eY1C0Y2OxzrsCw-NKD01NZlaQCp9NWiE4ryvf-a3OX23lUrviybIDt2JG_QyTmDeweNm71i56HdqzbkdQp6sYf396_7rG79zt1XdvsuVr7b7pt5gzkAgwHrgg1wVddhy8i1mYfOibqdqsK1ZCa-Kg5x7LQqHJPpRzIz9EdM2SaV3md0SC_v9a9uKgh7IfXj1jZ2Bk_F7eOrJobE9Xb_MT2s1emFwpV38JkpnpuDsqaqPmK_GZu7oM3nIdkLO5kmt_h6Sudflo_z_X334s1LRndxzs-GK_UatA8abPj_ZunjNVFD-e6EbwcfLy8vL093R2tSOqmcXRQK69HVTel4LykUCgd7OztbKQHIX9N2beXTvKmno0JuL7OztbGxAQAbG1s7mb3cQaqukMsdpCSXy-3tZVJF6ZLy8vJygE_j-y_0aTLL0cnN0dXVTaGQ-lU6qZycnOT2Mke5vb29wl7uaC-1oHKwd7BX2CsUCjtHR0dbpdLW4Obm6urq6mRtW7pYJpPZWTuw9FBWVlZaWlJSUlxcXFRUVFhYWFhQUJCXk5OTc-sWrKnXoH1Ur9qZ9ar9l6Ye9aq9t471rLT0qk_Xtdx4-WMT9KxP1_UctNfqVfuvTCEOloNeC1o9gDYrFLK0-tBMrV4Plo97yaLXZGozQzMz1epQvZSRkePml16g3dFMGLFswYGho7MOm6Z5rYel6YrQvDNfaBJXrg95ISLulf3vHYxeZ-71bqykgZjMFk3FCIB1ZrPRBAyYzFEGbAQM2IzNGMxR1hzGBOYok9Goevd_9S-N8bTp0TTrzk6jYmbKVOcDAT9Pmv4_MW8uJk52y1341hsFuv5O6hv7ym-u2qwfPjmvTjrJn6WJfDQgf-HyQP3gL-yu2LjNWpFqM2TpTjBDLzBAKnjBXVPrrkM3-2V7v5geV1lJfnLTF8su_g7JXcJDL4f_MKxli3WQBS4QAesgGwYW7Fj22ooBLhuqdOTy25miNV9tXxrn_xbMcd8-ua9itnmYtxOzqPuI04sXeLY-HN7Rd-XXXafNXHv3VvnZrz5Z9SGAMGaZS0punxWX3Lv07KdY45fR41QK8dM6_URd0uK98OJG70UOb3mtWD7y3-c8-6f8e-e3P_4MAAaTaYsutTmM1OqxLpg0bFSf1Mdu7Ff4nqi1S_AMfj17P_jMB9Jnfie753al532wbKdhAOd6esHxHw0vFK_o9l3Yr7E3wk73-bTF3OuDPms668oLX3Ze7v1ek-UB-0Nln-p8u0ztnHNkW500hz9LX9ijeXn5F2TXuDHXb5HHAxaNmej9eX_4FRxgNbwHS-GLC13z1-uG3XZdFHmhstJEozx8-thezMLZB6m1A1v-2At1govQA-bCWUiEhfO29T0xOtz47yodNZnZi2nZrImHq0pmZwNlpSUFebf_yLn1-81r585-n_HupqbvDuY7-7dq2qx5C08XVzd3D0-AT_RD_Tv7eXl6eLi7uSosAt4igi1JJpPZW0S33HJmTQDqD6-0F2a6u7m4uDi7uLhUCH2lUqWyqAr2DpaL5Y7Sh1yulNnZ2Rs8PT09PFSS5iHJeqkXi5i3CHlJxEsC3iLd8_Pzc2_ezM7OrpN8r4tUr4ssZz1KT31mltmUl-SAo8zJwcXJVgb2UAK5YAflUFR-O-dW7uWD21c0H1tZyb_s9-wLx08MCm7nVHpZJrMtLga4A-_BLLgLTcEh97cbJz-P2y5W6Uhb5b9HxFrZnAZ6IK3NE1epWSY2rBSui-yti8TtUaGkWD4qUzmUWz8r04HK0p4VD2Z56ooPqUbFDVTM0nMPNnhPpuq1Wm26OitLDeq0rAzQZkF6iFvatJygpdMgXdDrm-WcVYRCDrhFCxlpI5DFfaBXZ2WGpusztZZJ1oO-oqEsrTorU5ueCXrQqrOytEibow4NyQTA2AhgBgNjAqMOGB2GKGw2GY06IzZEGYy6KLPObJG42KwDsy4KzKZnTaZe2XZt_YC3oTm53zuYvLbRaXnsXlh3udOh9FGx5-Tr1yyN__3EkqMDQ_q8sfFNXzj39d3opO8fbmKm8JIApwLKzv_0zYBDQeP7-X-TH9nRONFv4UcnwhzBo4P1qrOTFREb3vbKl6Oy177erVw_lqWGfmg7syihvb0q_9CMJitaL2v3ZXy_5ybsIHeNU27uvR0Amm3K7zFx_C6f9okLdC0_aTHgvOrLfYnT5g461PegekDJ1wM7Xfw11_341IKT2364oliw5p2oXtKcGkBvAsOWYP3IDnqsS9KvPZmdXWi4Wph7bm3zc_Lt3e6WqI4kHFHbLZsSYtquC-l2-VmTqYFx_iGHPWCbd9rzQvsDQ88fbOYAfddteac703aJucsY_YCPl2wZ3uP7s7H91y2CDzLWtR0_9uEmDu8uL4KBicUvtkmfsHxch7zkP8aF72vVIso8bWBBFmjLKq5aNLRPFz__1k2bens3UVbY0Eql0klpsVcr5JiU7OyswhIgeEZ_35bu8pLbN345__mqQSG3ta6_fnbUc9Wwnu29XWRFOVcPrb_58YqRyN_HRelgB2WlpQBbLhBqqlXLFq09nJ0loerqolQorJLVWSkZ0w729nIHe3uF3FGyoe0dZbbPmkxVHT763ndFoJy1PG4gp-ndv4-AgfSURtvRtri4sLC05E5-8d28nILcK-ch7cD5s9d2PtwE-aJfPxgzuUA8OqSpy00mmOknJstzf_zs7ojoNi0huZ_1qroIoYoHuicBXrOc35e-1lW-0hyyypCetk_czbMmU7tL43IAAqvTLh55kEpR-mDqZjPbDqC81NJE-WwJbz3cv7Dc6lKokKnaTIDMTL0-KwG00RlBO0JCgnYIIWkj3LKGHi9JW5KTkIFCp2X4njqQrd061C-UkWoc35EZlOemDj7FuLllZUeQqzem-zS7XKBO6CZMmxZ83u-s33W3ghEZQSP8EPILCQIAHKUDI5iMYAYdZgxRJjBLMhZH6TBmAEByrWNsxDgKY6kIdM-cnfqrcdGYva2c7UIZfduUedOlrEWdV4Q0o5ruc5u2Ks1leEuhf07e7wuyu5dVVqJ8vTa5Tbk4t2pTyUHo_ZX7bjc_eZn4l-8qQ9KSKjz_selU2P_0CIuOW3i2Sc-Xt69ftjBxeKAPXD2e-fm2lVEpC1dmXlaJMctOKHokr__BAQ2Jnwdw-vWekZMjPtwz6uLLezu8dWfi7Xee81YS4_YNgrOn8-jUsildJmTkdRk6pmde8hd75zs81_UPyd-gAwMEN9cHa9fi3OZrTaGqrNDU0CHz157Eq0JRxvyNmblDTp5b5XkX-4Pv2TTtppHPmkwd3uviNvd3XwkX5-W_ndZkoZR18s21_dkmrva3VpyjxTmrfvr-zMiZz6fLnSsr7XnZYfnx5LT4qk3d-CRot2Ksz08_HCUHRAcfO6t55cHSIW8M6h7QlmjXxMNDEmEPuKUVCoXMkiyWpuQTtnqeAdbMfpH0JXxcnJ2UkvPaFsrLSkuKi6we4cLCwvy7-QWFxSWlZWXWtbi0GGDLsbm9e2u7t1UTLd3dPbw8m7pIstXVxcNZqXB0cHCUK6U_eye53E5uJ7eV2znYPWsyVV5048rtnJu_7Dl69NMQT3cp6_SKl_r7d-rYwtOjvDTv1qWr5787PCVWN2RKZSWHTvLcrLfTzlRtyq7_ge7uf-x5Z4KfZ-GlC_siz5U-WFrVTq3jAz3ehWuVqY12avcH77Tsaymr6_3CKmWHKivtsRSV9a7a1G4bS5VZtvdUmH0PFKqVloNWr4acrB0C2pG5oM-I637aBTmHVHnRCxZkd5ON8PIxbV1fkJY1qqjH3MXdvqA5LjsjNSRE3SzzBdv0Zt0yFN6hXxWlagGy0FIm5NSrwtIJstbNvjtQsMhXtmz8MWVQ8uaDkcZZn-z5NOmrwQt8fm7hcSxIPzpP-wtRpg8d0Tlz9S-rl5ZsXXYqdb02NDRduh2zZLaazaYobLFhjTpGZ8TYbAaT2WwGHUSZGDCAWWfGDDbrpLA3xmad0WjAAGaj2fJIJjCGsHZ7p3dttjtzZd89befe_eDV_fMvF984ec7nQN63-s53VIu3Lo0OnpTndEZPqbpd7hjQ1N5nRvd5E5SzL_T6cqz2uesH3-xZejSjnvHtyvS0RbqXX3496Jt5A6OPrvpuyPVXhA8cFVsGzXPXB5cbzgZI5cN95rQsUg02zTv5_PBNX85KTwpsMiJ06tKjw2YsntEWAC5NWntn4Mg-AC_-lNk_MvPOFmhypclr5NYOu5rM2PSbsqc_damip5-HHlvmKO_b3yt-teLjGZdfaGsbvbbLKa9zhLX4eLbaVzoGpQ6YfPsbInvJdx7631qEvW7cq3r7xesnPCYcY9-_1q7voG7elzYk9fdnjJu-yXZDzw97KdD9TCr_0zz61p41a3b_Rk477DV0zhIA_-FjX_fsfGVaq8K-o340ae64jNnrHeiUMHQzxEV1tz2y9uNrEYNjZCpd9tRfHI64n3Y_0d7f9tiWE32JZPcrrofPDaY_veq-NF9iyKAzGEwmkw6P7DAyqfnOtblXzw0ZYlqWnh1MqhJedjvULddv5-Gtmj5D3roU-3K2DZIPzCVbtz03Zv_1HUNnJaRGJJ8PlSUYDu_d8uGcsUz3yH6-ZWc2zBzgfWHtBDJ7g67tubR-xZvDvA5O6Xh2YeBvy0NkO-M7_GwY5nny_ZHqG9vmDetk8-NO476jH80eH9yDr2fkvDI9bTF03VGI7j_hRlbry_tW9ts0YUCw45D8kXM8j2kPDPeRyi8Hy7qEXlr85oEXBlCfNNl51flUr-Z9gqK29rw9JSBNUjnL4o-OjvMHONFz6sLE_HfywH6c60vbxFnn3radVzJq8nx1QkVPS_sQE4f_ujriE_zOD6t6DVu67bsgeci3CbOftxaXnAq6Kh0H75w1bsCgLmznNm3beru5ujirVE5OSnuromJRYCwKi6zCfS67F9CWIto2NjYWP4GdHYDytd5iqxbenm5KKaIuk8kdFEonlUrl5CiF4CXlpqSooLCouKSs3MbG1samvKykKP9O7u0_cnKyfrl05fyp7w9_PjcaIPP8sOAQPrBdR7_mnl5NmrVwlRz77l5ujk4KJweVXCVXObjK5HYOMoXM0c7RTmHnaOdoq7RT2trbyGzsK_7kNgZna1Jaw_DWSLzlISpu3SpayiwBeSkiL4XkpZi8NSx_57Y11TMmX5nqF52vTA0Vp3eCvCt7_1A2nTShhVJRXlb4-28nV3c9WJp38MTUPKl88u0EhiE8bBVN3AqcnWVuqnLb33JuXt6_YcRO-W0AmP5xeD91myIAn-Y2J46OWe8LvZUthx6P6-h4zSPHdff5vS9QFT1NPUOXn-w_v-VN2bWXS9NOtwm5UTg5MDT_gwrJfeR9aAN19GFUlx4fz39I-apJZepZJ72suvSf6FC1aon1jvY_2f38J6mhdgD0qPR8PDwQ-6WsbpWXVswXVLmg270iqz5Y2gc4hU15RYDD8g3fX1F9h_z_TZ7ZNlJumR4kV5cltHFPQ9xlWRJAL6t6f5YdjplSZCJTm6lPzwKL0qYHfXqmHqnT9elp2pwEOB4CGVnapaEFh6-PUANgM-ik3YZRZhMwJsZkhigMWNqDaJbCEsbk8B7r-vz40Wj8v2nlfX6KXtpul03CcPPqce13jdz3YWqtOlh9dat-b-X8WrwfUmGbtDFBitH6ALwKZ0D1db4pIRQgO_fc8WToCjykAYRZ9EuYB1sh9of5c2c3BUhQoKtF9onFN7t9vnKBfrXj8ynafm-WrozpF1v-06qBAM065MpM9tOHdmnbe83o5DtXett3eL-n44zoIJfP_JMXAGAT6Jrrgrd0SFVpc4Nxqkp7KDdriFbVL8ukfS-2g8Fnn-tXQ2bOHtt6b_C-Tq9_7_fqme5belz8aM68jb1yul-qVTeor8zfPnP86YjfIAa-g8EAUQCwBeB5WAIRXXZ6-78JkPXtG5dTIRNWQh7AIACYAuAAPeHdmwuvru4MMHz1qKBATLTz9faQkqfSsmPNzcPLSToC9J0dTPqp3VxUSssONcu-NEeFtDdN8sQDeO4qat-xTetWXs5SQNrZ2UUlxaslA97BQeGgMKjbtPFwcVZZPAcyWygvLS4qyL-Tl3v7j5tZWRdqlVm1yaLaZAxXVlKmdIJO0BLk1q9lEUApyEElk7k2UQMsPLJ9nAYOwW5oDfCrtZISUmBgcy_3uyW1L0K13UAtsd2Hm3_StbS226tt7etRxelrXYvgQTdwNz3cj8NaPqo42uH_AFBfXowlggAA" }, - { title: "Ripple Orbit", caption: "Ripple and Orbit \u00b7 12-fold kaleidoscope \u00b7 Spectral and Indigo inks \u00b7 Night", video: v2, poster: p2, - remix: "https://sketch.syberlabs.io/#r=H4sIAAAAAAAAE-2cC3QUVZqAbx7k2SEdAwQUNRQuD6dS3vcjeqRHWJQj7rCL4wzjKyF0dRryMh1eKtqOijjDuvG5du84Rl2QcT2aXdTB12yPOzLo6daMqAPquFkRBB8jDCKvADvV4dFJutPdoV1Ctv_knK6qe2_dW_f_-v_rv39XZdykmY3N9dUtWqXW7PY4NV1b5Gz2uBsbtEqsa9VNTUcLKjwLnC01tQ5oIANqeoFW72yp1ipv0lrcLXVOrVL7YUN4a56mazXNzuoW5zytEgmFqBIYUyWkri1smtfzOJNUUF2b11gz2xk-jpjCDCkldK2mcWFDi7NZq6RC19wNC6Z273usPl3N1U217hanVgmtsnluV6NWiXStcYl7XvfBxpra5u6t-kaPJ7zR3OjpPuJpcta0NFfXaZVc12oWeloa67VKuEzXXM2NCxvmaZVag9tV22JdSHW9szl8lTVLwk1rlnafoabaumZknbTFartML9A8Lc2NC5werfLqgps0t3UaWL8IEr50bi2EEEedFyak0rV5zkXuGmsOm5wNmq41Nrtd7gat8mqow2t17caIjnTNE54oAQXFSHBmjbHOPdcaYl2jVgkNSHWt1m1tSV1zVdfXV4ebm3WWipGuLbrCag8NpWvz3S3hCYYG0TWz5gqrT2KwZfrRS7FO2mCdW5vbvNBTq-max32jU6tUyyzt1DU2WxXcDQu0yhNTqmsLwsOcV9v9MTP8UVdTq1U2LKyrW6aHcbMadn9qze6mprowdmHg5lZbSsJWD3WNHmuoZnWdx6lrzdXz3JbKWpoXOnXNA7vVsbB7UpqdnoX1zu4udM2ztP7Y5hLz-MGW5jAbStc81fVNdc5uKJoaG-s8WiVapvdQmoB4MYSQRlWahAL3p7QKTGLpjXCEGEJ0cOoNnSZ6w8f1RnrrDTMEIeTR9EagoCi23pBSBpJMMgwZFBJhpiPEYugRcUokVCRsqHopUmGhLIGUE8y4OqZXoZCKKBCnTM34NFEzPa5m1lvNvFZBCGVUNXNimcVYaq5ITs9IEsoopLCPnhFFRDClJOJSUUaOq1lSTlTk8VOkZnKaqJkfV7PorWa5AEMIq6OqWUEm-7XCiMaywkgRoRCGCvXVquSQM4UE4UgwRsRxtQohuRTqWMEpUytNQq2NzXPdLd1aRQlo9ehuatQqj6tV03rr1VltQgjnpvXaV69kkCj2amhIziFm1PoeUkqk0KFBVU-hekW0g1HacmX0rKUjaQjGKSVUQCQkk9cOBCQXhLAmDVJfkPjgAYnBHoL68sEtjvoci9ISYWRgShjjDCuJEe0L1oAxihq6_n_HSA0WjLiBMMGEKMWIpBw5K5DQkV6B9OglFOrdmhkYDM40DH1hQHiw0NDX7ci-VkVGsSoyiseSfc0K0THsZVjEgFky0yxFYYkNIpYGfqsTrXHfex3Me_M1YJhcaZiiwCQHDUxIRwbGlFMulIKCK4ydFYjrFbEO68gQiDLIGJLCKoHSWYGojuXAvVdtGpK-kGA0aCBJ0C1FORS1aW-DI1JpcNxplqKwRAcRS72ipGhOKUpMHq7Wt2nvWyEidYyg0aMaHzBM89MwRYFJDBqYkCEJVwIiJQWlnBz3UXqsou44qycfAzc2C9J8RFsTHCx8QIPFWKmJtnjT15hQXQz8pqYuzUYUNgbvgnF0BxP1UN-WorfL6b1ezDEdAEm1TR4IYUM0khhUuB-SmDAIJFJRSLHiUjC9gmBqKMkZExBySZiisUCjiEkpIO4LGoYKK8ihJJQwitmJNCJHQgksKYKIcXyCMwMigpGCFErIiLUo2hc8SRhSUgopMRUIfpccstMkH6X6oWL-jVY6uTFNRaqpGLzG6eSyWYhRQyIOEaZEUIKk1AU2kEJSKiGQ4pDHykPE57ApzWGqORyayTDMsCEU5VxgQqVgWMcIGwgpBTmVkELIY92Jx6fwhjSFqabwdM2lYS4MLjHnWHEiucA6kdjAClKGIFUS0li5kfiYNacxSzVmQzdLp5hBFRSCIIUU5ExnRBgIcsot-4cJZwPm0JPmMOUcDt0MH2MG49gKRyWhnFOlc8IMKBlCEkvKY_2WqX8OqyGELWkOU87h6ZwcrECIGhwKISWUTBFBuc6pMpQgCFGICBdoYKGGRdvCNG2ppm3oZhkxVwbFRELIMCFcEZ0JYVg_occUUyQQwgMGcVEaxJSDOGRTlIQLg0JMGSMYSoQ50SkRhrR2BSMEC0RipSjjo7g4jWLKUTyNE5x9Qt2-4XCsRER81pakWUv9kvNgYS3ZZCmxfmahJOMICyEpUVRHCFuVOYMMc0oYHnhgsTSNWspRG7zpjZPKvSIBDSQgo5RxS_fhn9MbUgqlmMBSKQzZQBaW6-rnQohgVBCZkqK_pzahQZGUkmIhCCUWiUwZUEoqIGVSSetYrIeuoXURlIm-T11jLATDgkgMrahdnEBRUa4okYoTKpDqgSLBSEDJKWQYYkKioCgEp0RByTCS4XWqVKJ49H0HST9pfyqTrwj2g0XDfCeECKWxGKJYpDr5SqChKEQQQUoYZ0pQHRsCcmU9rY0hZ1zIWLdkCYAY9U0daRCHBogpzL4ybhCJGJMcc4UkVzqGBsMIIy44x5acBIUkTeFQpTDJ7CtXBoOCMCoIJ1girFNpcK6Y5EgiTiHHsdJeCXAW9RU3ac6GAmcpzr5ybkgIKbUWQzDHlOlCGRxacSHGzPK7sfIQCWDI0hgOYQxTm3yl0lqBQERYa3WQSWudBDIDWi_WolaWjCmCBo5i1JdHpVEcEigmn3_F1sKSEFwyFlYAFDpC0kDCWhlRSISXxQYOm0jDNmRhS3H6FUEDWVOMqbRWOSm33ltmUAEhtmZXQQJjrQ4nAGLUd6mlQRwiIKY2_coMRolQEAqMCUJCV8KwMgKYIoYE5ewk4l6V5nDIcph07rVvoNsnGj4J1KK-VzCN2lBALdnUK2WG4pgLSqhigiqqE2kwLjAjXGHBMTqJe7yo7zlMczY0OEtl3hVLA1HLDlLEKJIUYx0RA2EmFEeWThVlsZ606MlhwbXLCsC7GaA_Mf3-YAiEiMv0ExJZMDpyB_Z7jhjS0RWzU58vGAQz15qrfRt7dLo1cuehgXQavDVWSc6dM265Sl13aZHd_n6PTldF7jwWuRNIsNOLY5Y4ImZ_SmRBj50e7WNeQE-xd3-EAAjooH3fCjBl6rSbhyXWNgkx_SRE_IQQvx8Q4vcRl89PgIv4gJ8QV4iQkDUGksCZejMVWwZE2yCTYP9fu2Ni-kiQ-NauJZs3BwiZs9ljztm_O7B-tq9NrfWs1tdO7Wqyv7IzwendmkCdgX6vBpkEvQlVW51xkBol47RxJXa7faS9qCgvb1huXn5hoa2oaHhunvWXn5-f4PSuSqBObwsSWxK1LadEfpNYtSmZCdVK8GSJVYtta3tIglY0tdJtk_1-AOYsBedtfx04pk7Ly_0_6twyxEFqsewDfuAHQWoS4rKMthnyB0PEHzL9JqE-l1WZhIAvQewjJEH7HSFDwZSH5cfZybboSLqPIADBIFnr9QbGOMYEfA5b6fLZsyeU3z9bmfqcurrZyjZ98rezx0-eAMuhY_xYR6KGK0ISdBERMhS8RVj-PTGXESEzk26xEs6rueTi8nHlY0vslsspKcyzJDvvmAzLzs7KjpDkNZigF4qQBB1ShAxS35Sg6T-pFom5tMgGyXeRdIvkL-OUuL-4cjRmIQAExoMR216y_KM3acMaRUwX8ftA2MOFgGlFs4RQXyhkEkrMkD_etyyeXzvlXuyaOOXxfI1pEp8PjJm5bc62lYEJfw28J5TeP3tM3UzTQzzTu5bGm594XuOU-4h4tj2eJV_91r2uK74_8Zxzy-2W1bbbi_JOyPW58eYnnk2OZ4G_c3sb734-noGZ0n80G8-ixSuP1_13bc0i7ttngLFb_tOyS-vyjpV6vY6_qsgb8AY6y0E5qCr3BkBneVtVW8DburO8vdPRBr2gc92SptoVK7zt-8ovax29qX1SLQAOh2PDHd6macHtY-HDY2fJj2x5lzVNot5pDts1KwJrgl0dV64xf2lWfrt9U-c2X81H437-z68t_OSr0ivPmfYFAMQVBMBlEgBcxA98xGW6TBAyXaYZcgHT9AO_D4SAnwDiD1s74jf9BIT8xBdymf6PHyq66o6X1n095nuXPlDpmlF6QOU8MXPa27D9gUnP37nl7Jsu31Q3qwO98ty1B36f89WvfevrXnzvs5LLpt_81O752gDu8yPllFvL1MgCAB5MrsWSDdPP_sPHIvjWO49W_urV0lXirRsm7vlk18S77K8_vnrjXXT7o1flPD1tA787kOGafUHXyw1l6678-meTO7wX274AoME-bfoHgSufeue_P_3L2IW2XfD9HY0L_vTbktf_MOmlxVecf_AZfOiFO869o_2xOZvPe6Rs2bua-9l9M1aV3Dvh3V_cCgAxg5aZXwu85u7NDt9Gc7mZ46ibsC1n0Zc5gZzp-8vH_KpjZtXdBy-70Pdx1uVb7h3vy5yx--FNe3bf2Tlqm6807y8dTyyYVX9JyYdt1We_d7fceg95f1Hps5d1LrY9xt68bveKc38968u7JwdvOPP3i87_5JfXn_fnV-979oFrLija--UAYopIOeW-IzUyP2larv7Z302fOGHSWSNGjBhRWpyfl5ebm5uTk5Obm5eXX5BrfeYXFBQW2my2vJycnGHDhlmxQ1ZWZmZmZgYAX9wylU0cNaJkeGHesCxw-OCBfXv37t2778DBQyBzWF5hkb20yFrkKioqstlyh2VnZVmNjhw5fPjQoUMA7H5z7rQLKi8eP3HiqBEjR5eV2mwFhUU2W9Hw4pz8nPAoCooKCgqG2wqHFxeeccYoW1HW8MxikJlZAjJKfHZ7cXFxcWH3eHPCA-seWUbG0U4OHzrU1dV18ODBAwcO7N-_f_--ffu-2blz586vvx5A_BIpyccykTJo4ppbALgtuRbJBw2R0vPCj0RIP42O14jj_fuXOL79SL9ykpedfNwUKYMmhroIgP9KqsH2o3kgR1WnI9Dm8AY6qlpn2R3Qu24FLJ8FArVtsglO6pBrOlas8AJQ1V6-LhBoHV1lD7SuaF03a8Ukb2ebt3W0Y01bRydYA-lM2rRh0nPbw6EbocGQlfjxuQhxmSGTABoE4aQQICDkMq3wiwL_9Atr0B93bK545qK3F_-w7dF_HfHjbeKNM9d8eU111b_djX73Dxsnlk1--5HXX0riXuO7vK_Yqr3_4UVzske-s6tw5YYlcat__B8vPH_W57FKrx3p3TH3vRefy_ooq2rP5-f7Sm-4Z8bcJ6a0vft1Dvm5li1f_umy-55sm7sJgMCPHml_9n9GZGavuuFf3vrkvKf4NS9f-efND731u2eoOW7CbcvLV236zeQ_bfiwe-ZJMEi2zVw7Zxshprly-tqq-2eC5S--uNHbfnuVzte386d3F0Lfmd-sEbbdd-Yu-fCCxWs_tenTfzJ_6fIHV724cfdZM1d0nHfXvhttuzf8qHBrEn77u_TRb5Y5p107o-Knu9x7tG-ej1t95-hLJp_njFXq9k7jE8tGlp5ht-fl5RfahhfbS4rz8nJzhlkuMCsrK3tYjpXyAWCxh48t2LvtvYe_WvXg5WcV7t_xxuPBN1d-_1zwySs1r37pqrygJD_jwO4vPgAgsKXuUq7peJw2rqzkjNIRI0oLLLEVDi8pLigqGl5cVFZakmu35_hGl5WVFRcUFOQf6zEzAxw5fKjr4IED-_ft3fvtnj17dm7fvv2zJHxg4v4ued_W4l5_PZyQse-dZ1_8J3Np3OpXv_z31bevjVXqSLjb8EAt437Y22frmMlN0u9EtfMn6U8S9x3J-wmZeWKEh9fHrf5qBjhyaGqMwmO2Hni9wAscIBAoL2_rrKoKtAXaHLDT0doaaO_wts5yeB0711nTXxUA3lmt5U2BprZOe1ugrbO9ClS1epu8sLXc3tnROqu1ddMaLwAhEvL5_SawMv4-ahl3E_h9hJrAijfDsaifhPym_3Sz9SmVdm_XjVNfe3rqDU_-5P1zzizdv_HJpT-A5X8z9anPJ3reVu3opUvfq_r0ujeuAODbpzxX_fEyY5ocueO1p5_77IpdHxTdc93Nn9536-O7qrc2H2h6s6vrt3879kI7AMEtF-1QS1e3T-X5b0zeEpgKV76w8-6l47NGdYxZvmWq-VbHSvO223-Rsd53utn6lErZP1ZVOsZPmHzWqLKy0SNthcekYNixKCocP-UDcNEtP_jepPElxUWFeTnZmeDIoXBocrDr0OEjGZnZ1g8CCgqtIAuAlfszMzKzMjOtgCY761imxopwwv-Z3ZKVmXW62fqUSuJGtNdA-wk8piSaBjjdbH0q5X8B4SBYlKiFAAA" }, - { title: "Paper Moth", caption: "Burin and Plait \u00b7 Mirror symmetry \u00b7 Indigo, Oxide, Graphite and Moss inks \u00b7 Paper", video: v3, poster: p3, - remix: "https://sketch.syberlabs.io/#r=H4sIAAAAAAAAE-1bCUBTR94fSAghBAgCCogaHxTRBpz35uUdSG08qni1sR6Fr1rDlYAGiQQt3tGqxeq2WN1KUo-0dV1EW7FYxW5tYz2qNdnSqhTPxqosKioKVRSB73sJKCrQrfptu9UfIUzmfjM_5v-b_0xcZmLqjMz0hCwsGstM06dgMmxaSqY-LWMyFk3IsASdrikhUj8pJSspVQGj8CiIyURYekpWAhY9E8tKy9KmYNHYmMmOUDImw5IyUxKyUpKxaJxmcZKlCUJOMVCGTdUl3xsvRyxkZVhyRtKoFEc8omiCJAgWybCkjKmTs1IysWgCl2FpkycNcH7Wc21qMhN0qWlZKVi0Iy05TZOBRSMZlpGdluyMzEhKzUzBoqEMS8_Q6x1RmRl6Z4xel5KUlZmgdXxImqrPykjHouFsGabJzJg6ORmLxnQJupRM7kES0lMyHU-ZlO3MPd1ZQ1KCNqWp0iyu7GyZCNNnZWZMStFj0a-KZmJpXDUwfRpE1AwiDUJItDouFE0RMiw5ZVpaEjeGupTJmAzLyEzTpE3Gol-NxKNoEuEQQYJGJKLlskicjWIJxDAsTkFcThHy8TJsRoueyDC9YyQRgxCUMzjDPYQ2LZF7Bm0GFg2jICnDUtO4ECPDNAnp6QmO4motxwFchk0bwZWHUawMm5iW5ZgBGIVkmDppBNcpFCWfLWt6Vq7SyVzdWGLmVH0qJsP0aTNSsGh2Njd92oxMLkPa5ElYdPMkybBJjk4mpzr_DHf80SalYtGTp2q1s2UONnLFnH-xxKmZadyYTHP0LTGBm0KCq16boef6qU7Q6lNkWGZCcho3oU0f9bBpbp1Dkpmin5qe4mxChumnpzcHs9V3IrMyHdRhZZg-IV2n5eYRyjBdRoZWj0Vj2GzZPZNK42lyCCF6Oql_zEl9NRKXQRmU4TIURTIUASkoJymGlsvg-N8-2ShpEoRQ3tpkIwJBpp3Jvr95xEYxJE3TDCIgwtucaQInSTlL4fiDM03JWTnDsJAgaUSSLH1n4iHNQIqmSAoSFIUTd3kQBSmyFS4wiCFpxBIsRSIK4f9vzMCdzIikonAcJ-VUE0OiIEGwOM1Sf9j_f7wdSpCvJ0IIqaeU-DNS4u7qQUWxNEHiBA5Z1M7i0R5TGJpbPJhWmcJQ7TGFhLJIio0iGAKSiJTT7bEDR1BO4khOwQfpweI4IUe0HBEkQbLEXXrgJMswLMNw9gyyZAt64ATBMK0QhGYQZGlE0AjRBCW_hyByOaJwhiQhRTBQjj8yYZxi7jcbF502IS3rd1oxiHZ4wE6eCCFkn_LgT8ODu8tEJAPbXBva40RCOg0hTGyNEyROczPUFicIkooiIM5SctRkNmQkiiJxApIMQizDCcu2KAIhRclJhniQIjhOEjQjJ3E5K4cQJ9AdihC4nKUohOQsxUCcaUkRmoYkS8EmtKY6aYSzFINYhiJYRLPEPXRhcIRDmmYhzpCQgPI_LV3aXjZQOxRJnDENQpj0lCJPAkVarCgkS0SRELEIUhS3lLYtPtpjT9J0jj0prbKHpGl5O-yhoiDZUvtE4iQdRRM4TkGGYSgaZ5i2tqlIThAQJ0n2QfYguZxmSTkpp0jIohbbVoKmaDlF0XIc56puwR6CoBhGTiGibfZQDMkSLM2QBM7gBInuZQ_EWZZBtJyVI4omH5U8d3xPf8Btb9tLDNkOSVJmaCCE6qckeTJI0mKRkRNRkCVJhuDGpJ39TXvsUU9PgBCmtsoehiLb8YQhnBOljJzGabrJFYbjRBRDURRicAbJ2bYtFIWznLHgpOf97JHLcZoiWJJk5ZScJpk77EGIoBhuQ4YjkiTkLdlDc744msHJ1nbFFIkzFLcvpkiWpol7iUOzXO9pnGQonJbLH5k5Dnf1H9A0tb20yNshh2ZyKoQw7Sk5_tzkaLGkUASMwlkaJ5H8Vzyu7fEmTa2FEE5qjTdynJG3o3oZWSTBwjaIwZAkLScZCj1IDBoRUI4Yzn1HsRR9d3OMGBIxCBKMHCKSaLk5RjjJ4nKWZViCbG2TTEFEkRDiDKTl8vuYwSCGoEiaYVka4jTzWL1rhJMbKIqmacjQbDOcXImEURAncYqEd1N-b4db24sL1Q5JJkHOs6Z9SpIngyQtFhmcanNVuYcwovGzRUAqAi1gAAaDwWI2283SEggKJWaVvVClzK6oUhVKLSozKC5j8vPMzKyzw63ZWRUS2arDcWXiE59_8qnqckElABYD0OXr4vJO7KmIqdWdLY7JZyqKR6uk-8POjivcVLlndFZJhSSLid0UWyAITrwZJIrid6OCtgamXeaH2w9tGzx2-wtlw6YAYEM2NbJprCRQm4zApLapTSZk1NiQDQCryWQ0qZHGSKrVNptag9Q2kxrYAABqYDKpgclos9msJLACm0kp5-2a3idwp-XDF77qPv_6e7N2Lzpbd_7Q0ZA9Nd8Zev0ifndrXurwyTWepQZcHHO2R2Qnt5AZzy2YKJrzU79_vKYYULH3zefrDxaCQPB4AB9TPX9amHMlqjzjytTZotNDUjecWeA6YW3S-Qr7oX07tmzefvBS9ZaT_mM-vDnuQL9dQ49PERfkZH3tE7fmp2DlnHVfl52tOP2XccOokMZTAHRd1vP969r5U2OAcuURnxdmFl2G022Rpo4f9L24cdaEUXHpuZbbA5aVP2-6Hn2orlvf-PQZhjlZiYOfufXN2yPcitWSbfF178ecBMCKrOVo6XpxEVAfvWkOEgwfZIxHO9YvrV5ll9z2YIPSFvHWH6gOH2sNGdtx1LZxi6N04_KU2XPyd2wo3b_jwNaQytudJZVQbNy_q2j1vNfI51IGhTWUFswc2vGn9RNllQXq7kdzB9VtTvDfO61H2eLoi2uU_B0Z4aeMY_wO_TVeev6zBWN6uhzfYfr64AdzEof3pcG5xzTM7z2mev60SDit1yzXvBhNEt2wsM5-_gEBAb6iu3B3cxMIBAJ3d6HQw0Pk6enOhblPTXDj8_l8Hg-AX-b27xMa0jk4UOwp8vAQugvc3Pg8nquri8vdtlxcXFxdXXmO92a4cAAANDY2NDTUA1DMC17T2bOzxFviLfYReDc35iEU8t34bk0QOF7ubgI3AZ_n6tr84rnyeDxXHt-VzzN6OSESCATOMs6Ocl1yvdMk12ZDfX397du3b9fV1dXdcuDmzZu_XHMC_O0xDfMHj6key2Oq5w-HLxvnuvwb2X4VjzpAjQ4A0O-x9Ab0_bebbBfg-cfSm3-vP_8O5j6mev5ouKsRDQAoDAqFQiG12FUlBrvdbJDYlVLz_trUOF0skxsXF1dctj-3Ijfn7IrYEMZ6mw_3x_C31qzQrcmuSWwQlXJcrFIqIhhglkhic-yG3NjCiv0ReeZsJk-ZlxdRxSiqhBEWSWpZdmphdkWsMNcsrTVsLWbEydn5KunSmJpU1aYQrRYANcnJQEBaTVxQowYAAQA0amBTA43aIQQ1RgBsao0RaIDRZDPaEGnVcArSZlObbDa1zQaQ8alG_C-DZOvo4nXnqfQ1wz4v706f0x2P8GJY4hl_16ozJ06unPde0VGPAW-W9n63QV-VVb9kY2k3zcdVvSd-8P2tLszQ0WNfyvNzvXzkcxMAKfP8109s6NoV7Or79o-dx6853UP_zTN_cV3YqXiirPFM6bFKTyZjc-2gvEv9_3qut_6TU7yQ8FB046evVqYS17dqpd_NCv_ntC57AVAjo1GNDEXD4w1qdFS9XmEpkkot68MNskFmwbYd18H0A6VGAIePDT-qEkhv9gzxCPHjhYSLJVJxyO3wILE4vDIGhAY91Yj_ZQi8_NbEFwb3lodLsbAAf_-AgI5eng5wCtHDIcgcGtEpEj2aPzXLRHen8ALg9NzB1LNBQYGd_EUewvskYpPZvasSeTweJ9mcAtFhjBubNWL5j0kDolaFhAR5eXl7c7_eTo3IQfQMn-_G5_E5rejK52pqqTabPznen2rE_zI8LvGiaCftLg_bz8Jlev6pRmwPf1aNWCzk3hUWu8VssVQpDTpVidlcGAGligpVbYUqVQikVSUSS7ElGx5WFhaaz-6ZNXpF9kBlTe6qVREVZuk47n_UbrfrzECaa8gtVKkUOoPELjEbdBaFxWwxGIBCIc1VFeYqYKwkLi5PZUmtOGyAS784K4bK0QAgZLLZgLVZFdoA0Di8gCYbQDaAgM1osgE14HJZgQmYrCRSa6waxHkXgenUe15jF3xefCXo2cErojVD_G6xgo-GD_wOFq6I-GzhmS4zh5VplSX4F1vH3_pGcGm7cZ92x5F_-cYOmlVQPRF7RO33eyo-k3DWZ5G_V-M_lk6ZG_NVx8HdPE6uHnpu5I3ClI579d1_WPhc3VdLJsRgfj7-YX0T32k0fLD3YofoxMWffH_VK4weOGz4C0yYEABToPv2vuXPh98ozuh-aMmIrq71fi_keX7w6iTNun_krk4-67Z59sKCM2G6bzOPhc04MWzfi6ffXXFpSL6H9p-93-bYYrRagbUoKKi8SFE9XGwA5WK9wioN2mTQL7UsV22afrRSFQ4ODdkki4GsbkfI8uXlg1atRz23VZYbjH7CayUfTVKm9_c9bk7ocmQxc-5tVDrNb3Os_XXxB_JvX6vO6bZdWbm4p3VK8DfTev28ZkLY5Z3vbl4xrrfXjcpHVGi_py5LCDgrMv1ejc96Y4AiNDQsuEMHX19ficMH5pQo_JbgVIpDJjUpJScAiJs5OLJXkK-PWCQU8Fwa6-tqb_xSfa3qwoljZUW5qyLfeTUqqmenToGBgV5iL048-fh4OHSbSOTpCUCRe0r_3t2leOfg4ICOnmIOXl7eYnd3p6LjNJ27SCjkRBYHt6ZfgRtfwOcbJRIfHx8fT2eHm3rMc2o8l2YfX71TSDlF1M3a2tqaqqqqqitXHlFHPZp6ejTNxJsY-FzMI9XwCGhPzfw67n3w-yRFw7xfKf286yM0_Ss64_9V7Tyaxnk0ZdO3sbF-3yPV8PBwapf_g8GgsFgMKrvCLM3VqYBdoYTZxYwlDhry4wIthws3Bc7KXqHKHp2t0gkNkprYsBztupCcfMh5xexVUCk1BMbWKnJUZqWFUcblSisiyoQWSYldUqtQKPOgPTs3X1JrKM7Oq2VgdgUDLft1ZYoIAJBNTaoBMKmRyYaMpNpmNAETUCOgMSINd9apsVmBDWiQGmiMGqvNhJCG812Z1AiZnlTtsmz5govnSlzWBLWS5udHeGxO_TBw4MaefpEnPOuXDPjXCrL2HbAKCMBpMAEIACiObafuRTz7OxsiXI_7uM279M3JTl-eLUg5vBDEg9XgAkCgFoDC137-GB0uGPJNOs6rPn1gHu17yjTolEzz7qd7bQd2rJk-1PfTPauzYjtf-mpZMuNdvmvN3MSh8tAAEa_hdj0AP6x_LytojUgUe3jkLmzazpu9ZNH7R9sn3cwJKRpZs3aU9-GPZk0YMXDQyJT5fysB8vS_n-n6yl8sl_yIIWP_ZxQAyKpGagCMahS_tOiovlwcH2-5aQg_ZBcEjRKU-wmmC5ZuUmqV-3aHK8fGr9eOG1K0XJ37xoagIEFoqMeTql2OXv6py5svQ_tLraRNNE2rT9KdUFln7J6_4ApPPD71_WJb4DJgd_T4TbAEANXadupenLj47Dwv6fDZz9XuDh52a4pZ4CpOBl2BBQwDHgAH4LWlLw2R4b26YeHBnQKDQ_wkvr4d_Py8Wx4p8pvdLA5HS4uDQABy5g7o010q7ezD-XXEniKRkN9CPTh9Mc24x2PkyFFUpZswXNGtS-dgP063eHp5icWiZteUQCgUioVCkUOvuDXrF87D5MSTql0S63eu_mLPu_2utJI2Pnk-7V4j9urWoeb41X-F9giUYH6-waFgIOgCAkEo6AhA75J26o768LPcbs8-E9Gr_F8_fr_H8sEnqxdotGAgeB-MAbPBywB89Uhd_1U_zgOioSUeyXvzpGqXmKZH2NtKWrTDYQqaHr_V4p-_0E7d0c1i8p4ZuztLDu1iUCgsCoVdBXOhRKIIZBQ6WChk4sqq4H6y-GzOOpVyeJZ_qi5PNy4GMoGy_tGvxdxSrhv4lujziBkAKOyFujzzgmRtfra16sSm2kIDo9yfnxsrkUSoqgww1-K_SVcWq1IaCnMNBnNhbkmxMldYZYZlktpaAGxGEhhNAJmMwGazGW0mtclq48QLyZ2NcL4XAByXtIyIc7YgRxgYAbIhQD6p2mXwlGtec_VvvCw5ORRtWPHiptyrw4bcOLBydwzPOrHr8irP0fXvTyx4ZeMC8bwf2Q8P3by_eBfXglHPHvvJEZ4enuFd2UCvWF7fZ1FgyJRPwpaVAgDWUsWDzw9cduP82w823inX9XM4IaDv7p5zD8k_y163o3r0sQXDegYIrn28z29o6kRl8LdJF5KODNqpODIvNhKXfnuj9-Si2j6vF3xXfhGAUt6stDWsVjEyB3wnLJ7zzBST4dbRi3Wlw1HakdSvDV4DqZqFr67Huw3tyHqv-rlP978ezBvaSRgx03xqZBkAViMCRqMBGY1gqVUcNPzo-nirjFUMqq6WWsqrzcu1UjscW71o1c14femZoEV-2w5RX2wB8-xFWzeaq59U7TJ6kU6x-oC_PeOi22sdRbv7vbJj3I8pB2qOfx11e1zPI0VT-cfeaXjt5PLGEa4-nqFTH9hzr5xSF-cxq4sjfHlMUe8ZfU2JryzMmTCpf7n5FnEbABCppH8QlLy3uF8rq-jiN4ZF9-gR6tehg69E4iN0HmiJPMVeXuLmoyx3Thk0qxfnRSZnGICds0aEdw8JELq5gvrbt25WV9dcr61rcOG7c64VAc8VNNT9cvn8udPffza79-UZLz_bq4e_A2KH32Up-3d5FAruHNipk9jLmzvX8vYWursLm64_eYhF7u4CN3eBu0AoELp7uAsFHtwxl6vj50nVLik_XTm257Dp28DbwS-OhF27dunoIRS7uIk86qslgipfUCcSu16_Vn-97srZ_I0VD8iFQT-Xrqn0T3CEc7M_PDxX7u4vPrvz4L4v_zJyjv4UJ46mns0MuXDcf22fBxt_bH6X1gXK3cg5DwqVp36Xh8BzzY9wvzbhxt35uvvw9Q8onK-4_cftQY7wdr5Lc9EmDAAA9HF1xtQ96N1RunPvBotBZTarcmFJlaHEXqsMjIXKnLLRTK511bpVcfmq2JhKf6WUHxthCZTwb733cgVXxiwFECgMSkkFY4nLLlOkKrLtwtRUSWzx_ly7QqIwF1eoFKkVuampxcWW_Fg7d9ZhJa2ANNoA0gCjDSFgM9kQstmQmhMpJsDdIDI5_CrcdSG1CQCT-J0NhpHj_Vz6drL_ssMknDn1da89kacmT1-Z9ua72KGY6sVLFtaqB3tKz3_deOmjzYZXptQ8lCb5TyqRfX3NszZ0cMN75mt9P5andfxo1pjDQ9aWXJg4IqQvyHjr762ve8NeqRm1auW0yYllP_CVz_eNSL_h0eXDN702WlfzQ1vJvrK0h2DK6pXrtVPm5uTMGOiv-wqgV7SZP_QKBGd2vZ85KOjizneSo_2u_HPLyvm62F7hoVIAtuS87n9p6rIvS48dPL6z68zDz75xgn2f_3r1TMmnid2vHNz2yae7jtWFvjR7Y5lL2PMj40b1kwIAjFZkNRQZh4Oi9Zaj4uoiizh-aXXRUnG12lyttcSryqWG5TvQPoWfsVx19Ob6owZgHEr5HMkpOW58sW5tzA8JF7TnE44M3BI8v2LEp51mn3vxH73WdFwesCZyt4q_RR3W-_VeVQc-eyjl8J_UCwcyZicfyf-iW-XU-QUdvu71qsspL3Tm4yV5hWeqY368zExb0Wqpq9cCJ03d0EFdviTZrfNCme984YsvWzt_l51WmSNuJfugBSOjkRQLC_YP6Ngp0NtxSuLt7e3RZF5b3hZpOoYBYN-cEb0juwb4-3Xw9XG_e7Xl7hWSe10WDhieS104VBrsJfGV-HBm3mHqnQE3HneH5c6Lz-PzHDFGPz-_Dh3EDilyR4s42mlsdJh4zsA7rPuNGzeqL12qrKx8KPv-MFb9YW15_P9sSHoRR9Iu7g11V3_p4Hrj6smT165VX9y-78LFz6alud1otdSi_GLFkR2MJIZY1jlAzPMV1lUe3mD98q9-K88veUyda9vCt-1YeAjL3LZNfLxW-GFs78Na3Og7msU5PPc_QGv-AwCec-Sv37PTpUVhh8VuGPBgbiFnUw0KhdliNkuUSpWy0FxYaC5UKVQq-z3THVfbFG-xGAAorI3blL9p7OixY0ePLdhU4Aw4w2PGjrnzuhMPgAlwl27VNg0CRuC4ZYGABnDfyeLsqE3NOQSADZFGE1KTarVGbeIqQBsLNnIvjEOo4x075cBJx3tTOzYuz0PY0v-kJcUqJWnnPIRDXk0M4anD1nrlvjt73KmSJm4Q2f2DL3361t4lx1BWy0JbdtWFdDzYebRy2aaECV8GVGp7LmpOKnsz5tntb_z9l407AAAFYExBQUEBGDNmjCNQUMCFGgta1jWmsSm-oHEMAAWNY5pyjbkbaAofaUJACwBgBEa1Gqit5UUgyKCIH67Q6w3rVeUC7SaDWX9zabl-w3mVWRsaev7AqNJR18u3bTNy9SFndwqwFjjZAk3NWh3d_-2W8z9pN1-q66rI2Lvmi58x8eYta3v6Jqh7BFycGuBMrPVDb07u0C-58yfie_Y5-R2Vb-3Xx--MjZcxdUfLqiIEd5YdH4taNHBGl9xj0wAAbxleiMYwLNDX19fXeSfy3Llz5376NjYiIsK5x_UW1VRWVFRUnPv--y_XugHQYe4QKiwsrAMHicTjzteJuEulzV_ycb8LNwCMcHz_XqHdu3NfMerk4-Xt4y_x9RULPcQeIi-RyNtT6M4XuHtyfn4PgQe3RzZyM-_XdObQ8gZofQv84kTlhQsXLjyEnfztVvLh97sT3pg_bnuYuPHi1ZpuTOcakcul65vHUNeciYVZrnPqvzi85WDhJ1dbFvIWTTu9LtOlq9Dn6t-GzJmyssOlsuakUdeVQevX648Yu9xn6Vq3a_fH9_vND_CbLWOb9uoxWsXfbhMffg_ax2nVmsa1ySg27HYmxgCX5hOue2zjl6CRa9JwZ5facGfk-4I5hrn9-4KGrwH4X7-cvFq_WwAA" }, -]; diff --git a/src/sketch-gallery/sketch-1.mp4 b/src/sketch-gallery/sketch-1.mp4 deleted file mode 100644 index 7ab0ccb..0000000 Binary files a/src/sketch-gallery/sketch-1.mp4 and /dev/null differ diff --git a/src/sketch-gallery/sketch-1.webp b/src/sketch-gallery/sketch-1.webp deleted file mode 100644 index 4431743..0000000 Binary files a/src/sketch-gallery/sketch-1.webp and /dev/null differ diff --git a/src/sketch-gallery/sketch-2.mp4 b/src/sketch-gallery/sketch-2.mp4 deleted file mode 100644 index 06cdbd8..0000000 Binary files a/src/sketch-gallery/sketch-2.mp4 and /dev/null differ diff --git a/src/sketch-gallery/sketch-2.webp b/src/sketch-gallery/sketch-2.webp deleted file mode 100644 index 6ab5928..0000000 Binary files a/src/sketch-gallery/sketch-2.webp and /dev/null differ diff --git a/src/sketch-gallery/sketch-3.mp4 b/src/sketch-gallery/sketch-3.mp4 deleted file mode 100644 index feceb90..0000000 Binary files a/src/sketch-gallery/sketch-3.mp4 and /dev/null differ diff --git a/src/sketch-gallery/sketch-3.webp b/src/sketch-gallery/sketch-3.webp deleted file mode 100644 index 53c1adb..0000000 Binary files a/src/sketch-gallery/sketch-3.webp and /dev/null differ diff --git a/src/syberlabs.css b/src/syberlabs.css index ca677da..a1cea00 100644 --- a/src/syberlabs.css +++ b/src/syberlabs.css @@ -78,7 +78,6 @@ a:hover { text-decoration-line: underline; } :focus-visible { outline: 2px solid var(--sy-ice); outline-offset: 3px; } @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } } -.sy-visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .sy-container { width: 100%; max-width: var(--sy-max); margin: 0 auto; padding: 0 var(--sy-gutter); } .sy-icon { display: inline-block; flex: none; vertical-align: middle; } .sy-subheading { margin: 0; font: 600 18px/28px var(--sy-sans); color: var(--sy-vellum); } @@ -131,15 +130,6 @@ html.sy-field-live body { background: transparent; } /* the canvas carries the s @media (max-width: 900px) { .sy-field-host::after { background: linear-gradient(180deg, rgba(2,7,10,.3), rgba(2,7,10,.62) 40%, rgba(2,7,10,.82)); } } html.sy-field-live .sy-nebula, html.sy-field-live .sy-nebula--center { background: transparent; } -/* ---------- glass + surfaces ---------- */ -.sy-glass { background: linear-gradient(160deg, rgba(12,27,37,.66), rgba(2,7,10,.72)); border: 1px solid var(--sy-rule-2); -webkit-backdrop-filter: blur(18px) saturate(1.4); backdrop-filter: blur(18px) saturate(1.4); } -.sy-card { position: relative; isolation: isolate; border-radius: var(--sy-r-panel); background: linear-gradient(160deg, rgba(12,27,37,.55), rgba(2,7,10,.6)); border: 1px solid var(--sy-rule); overflow: hidden; - transition: border-color var(--sy-t-enter) var(--sy-ease), box-shadow var(--sy-t-enter) var(--sy-ease), transform var(--sy-t-enter) var(--sy-ease); } -.sy-card::before { content: ""; position: absolute; inset: 0; z-index: -1; opacity: 0; transition: opacity var(--sy-t-enter); - background: radial-gradient(240px 240px at var(--mx, 50%) var(--my, 50%), color-mix(in srgb, var(--sy-accent) 22%, transparent), transparent 70%); } -.sy-card:hover { border-color: color-mix(in srgb, var(--sy-accent) 55%, var(--sy-rule-2)); box-shadow: 0 30px 80px -40px color-mix(in srgb, var(--sy-accent) 60%, transparent), 0 0 0 1px color-mix(in srgb, var(--sy-accent) 20%, transparent) inset; } -.sy-card:hover::before { opacity: 1; } - /* ---------- 3D tilt (vars set by src/site/motion.js) ---------- */ .sy-tilt { transform: perspective(1100px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); transform-style: preserve-3d; will-change: transform; transition: transform 160ms var(--sy-ease); } .sy-tilt:hover { transition-duration: 60ms; } @@ -155,7 +145,6 @@ html.sy-js .sy-reveal.sy-split { opacity: 1; transform: none; filter: none; } html.sy-js .sy-split .sy-word { display: inline-block; overflow: hidden; vertical-align: bottom; padding-bottom: .08em; margin-bottom: -.08em; } html.sy-js .sy-split .sy-word > span { display: inline-block; transform: translateY(110%); transition: transform 900ms var(--sy-ease) calc(var(--i, 0) * 70ms); } html.sy-js .sy-split.is-in .sy-word > span, html.sy-js .sy-split:not(.sy-reveal) .sy-word > span { transform: none; } -html.sy-js .sy-split.is-pending .sy-word > span { transform: translateY(110%); } /* ---------- header: progress bar, scrolled state, spectrum underline for the live section ---------- */ .sy-header { transition: background var(--sy-t-page), transform var(--sy-t-page) var(--sy-ease), box-shadow var(--sy-t-page); } @@ -167,66 +156,14 @@ html.sy-js .sy-split.is-pending .sy-word > span { transform: translateY(110%); } .sy-nav ul a[aria-current="location"] { color: var(--sy-vellum); } .sy-nav { margin-left: auto; } .sy-nav ul a .sy-icon { width: 12px; height: 12px; margin-left: 4px; color: var(--sy-dim); } -/* Signed in, one calm surface holds two direct destinations; the orbit identifies the profile. - Signed out (or still checking), Sign in is a quiet text link: secondary to the five nav items. */ -.sy-account { flex: 0 0 auto; display: flex; justify-content: flex-end; min-width: 64px; min-height: 44px; } -.sy-account [hidden] { display: none !important; } -.sy-account-member { - width: 168px; min-height: 44px; border: 0; border-radius: 999px; - background: linear-gradient(120deg, rgba(12, 27, 37, .94), rgba(2, 7, 10, .96)); - box-shadow: inset 0 0 0 1px rgba(140, 200, 235, .24), 0 4px 18px rgba(0, 0, 0, .12); - transition: box-shadow var(--sy-t-hover), background var(--sy-t-hover); -} -.sy-account-member { display: flex; align-items: center; gap: 0; } -.sy-account-launchpad { - flex: 1; min-width: 0; min-height: 44px; padding: 0 10px 0 16px; gap: 6px; - font-size: 14px; white-space: nowrap; border-radius: 999px 0 0 999px; -} -.sy-account-launchpad .sy-icon { width: 15px; height: 15px; color: var(--sy-mist); } -.sy-account-avatar { - position: relative; isolation: isolate; display: inline-flex; flex: 0 0 44px; - align-items: center; justify-content: center; width: 44px; height: 44px; - border: 0; border-radius: 50%; color: var(--sy-vellum); text-decoration: none; -} -.sy-account-avatar::before { - content: ""; position: absolute; inset: 5px; z-index: -1; border-radius: inherit; - background: conic-gradient(from 35deg, #0297de, #4cc0f5 34%, #fc734d 60%, #4cc0f5 82%, #0297de); - box-shadow: 0 0 12px rgba(2, 151, 222, .14); - transition: box-shadow var(--sy-t-hover); -} -.sy-account-avatar::after { - content: ""; position: absolute; inset: 6px; z-index: -1; border-radius: inherit; - background: #07121a; transition: background var(--sy-t-hover); -} -.sy-account-initial { font-size: 14px; font-weight: 600; line-height: 1; } -.sy-account-link { display: inline-flex; align-items: center; min-height: 44px; padding: 0 4px; color: var(--sy-dim); font-size: 13px; white-space: nowrap; text-decoration: none; } -.sy-account-member:focus-within { - box-shadow: inset 0 0 0 1px rgba(2, 151, 222, .5), 0 0 22px rgba(2, 151, 222, .16); -} -.sy-account-launchpad:hover { color: #fff; text-decoration: none; } -.sy-account-link:hover { color: var(--sy-vellum); text-decoration: underline; text-underline-offset: 4px; } -.sy-account-avatar:hover { text-decoration: none; } -.sy-account-avatar:hover::before { box-shadow: 0 0 16px rgba(2, 151, 222, .4); } -.sy-account-avatar:hover::after { background: #0c1b25; } -.sy-account a:focus-visible { outline: 2px solid var(--sy-ice); outline-offset: 3px; } -@media (hover: hover) { - .sy-account-member:hover { box-shadow: inset 0 0 0 1px rgba(140, 200, 235, .36), 0 0 22px rgba(2, 151, 222, .12); } -} -@media (prefers-reduced-motion: reduce) { - .sy-account *, .sy-account *::before, .sy-account *::after { transition: none; } -} @media (max-width: 1050px) { .sy-nav { display: none; } .sy-menu { margin-left: auto; } } @media (max-width: 480px) { .sy-header__in { gap: 8px; } - .sy-header .sy-lockup { font-size: 12px; letter-spacing: .14em; } .sy-menu summary { padding: 0 12px; } .sy-menu summary > span { display: none; } - .sy-account-member { width: 136px; } - .sy-account-launchpad { padding: 0 8px 0 12px; font-size: 12px; } - .sy-account-launchpad .sy-icon { display: none; } } @media (max-width: 360px) { - .sy-header .sy-lockup { font-size: 0; gap: 0; } + .sy-header .sy-lockup { gap: 0; } } /* ---------- the Atlas: a full-screen site map, from
---------- */ @@ -275,7 +212,6 @@ html:not(.sy-js) .sy-atlas__close, html:not(.sy-js) .sy-atlas__foot > span { dis @media (max-width: 640px) { .sy-atlas__groups { grid-template-columns: 1fr; gap: 20px; } .sy-atlas__panel { padding-top: 12px; gap: 20px; } - .sy-atlas__title { font-size: 26px; } .sy-atlas__group li a { min-height: 52px; } .sy-menu summary span { display: none; } .sy-menu summary { padding: 0 12px; } } @@ -310,8 +246,7 @@ html:not(.sy-js) .sy-atlas__close, html:not(.sy-js) .sy-atlas__foot > span { dis .site-sigil .sy-plate { transition: box-shadow var(--sy-t-enter); } .site-sigil:hover .sy-plate { box-shadow: 0 40px 100px -40px color-mix(in srgb, var(--sy-accent) 65%, transparent); } -/* ---------- spectrum text + glow helpers ---------- */ -.sy-glow { text-shadow: 0 0 40px color-mix(in srgb, var(--sy-accent) 40%, transparent); } +/* ---------- spectrum text ---------- */ @media (prefers-reduced-motion: no-preference) { /* stepped at 24 updates a second (each step moves the gradient a few px, below what the eye resolves on a soft gradient) and paused off screen by motion.js hue(): it repaints, so it should not run every frame */ @@ -329,8 +264,6 @@ html:not(.sy-js) .sy-atlas__close, html:not(.sy-js) .sy-atlas__foot > span { dis .sy-footer__copy { font: 500 12px/1 var(--sy-mono); letter-spacing: .12em; color: var(--sy-dim); } @media (max-width: 640px) { .sy-footer__bar { justify-content: center; text-align: center; } .sy-footer__bar .sy-lockup { width: 100%; justify-content: center; } } -/* Customer subscription access is distinct from staff authentication. */ - /* ====================================================================================================== Midnight theme (October 30, 2026), the whole site: Midnight Black #02070A, Electric Blue #0297DE, Cantaloupe Orange #FC734D. Defined once, here; pages no longer carry their own colour blocks. diff --git a/src/system-maps/Stage.jsx b/src/system-maps/Stage.jsx index 7c6634c..a8f9f69 100644 --- a/src/system-maps/Stage.jsx +++ b/src/system-maps/Stage.jsx @@ -10,7 +10,7 @@ const SIZE = { narrow: { record: [118, 50], artifact: [118, 50], gate: [118, 50], human: [118, 46], agent: [118, 46], external: [118, 50], vertex: [52, 52], hyper: [13, 13] }, }; -export function place(node, layout) { +function place(node, layout) { const narrow = layout === 'narrow'; const [x, y] = narrow && node.m ? node.m : [node.x, node.y]; const [w, h] = (SIZE[layout][node.type] || SIZE[layout].record); @@ -28,7 +28,7 @@ function boundary(p, toward) { return [p.x + dx * s, p.y + dy * s]; } -export function geometry(edge, a, b, layout) { +function geometry(edge, a, b, layout) { const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2, dx = b.x - a.x, dy = b.y - a.y, l = Math.hypot(dx, dy) || 1; const bend = (layout === 'narrow' && edge.mb !== undefined ? edge.mb : edge.bend) || 0, c = [mx - dy / l * bend, my + dx / l * bend]; const p0 = boundary(a, c), p1 = boundary(b, c); diff --git a/src/system-maps/system-maps.css b/src/system-maps/system-maps.css index 7c2ca37..2208a85 100644 --- a/src/system-maps/system-maps.css +++ b/src/system-maps/system-maps.css @@ -40,7 +40,6 @@ .edge.is-traversed .edge-line{stroke:var(--m-bright);stroke-width:2.6;stroke-dasharray:none}.edge.is-traversed .edge-head{fill:var(--m-bright)} .edge.is-blocked .edge-line{stroke:var(--m-refuse);stroke-width:2;stroke-dasharray:6 5}.edge.is-blocked .edge-head{fill:var(--m-refuse)} .edge.is-selected .edge-line{stroke:#fff;stroke-width:3} -.edge-hit:focus-visible+*{outline:none} .edge:focus-within .edge-line{stroke:#fff;stroke-width:3} .edge-tag rect{fill:#03080b;stroke:#6cb6d9;stroke-opacity:.6} .edge-tag text{fill:var(--m-bright);font-size:11px;font-weight:600} @@ -211,7 +210,6 @@ .readout-grid strong{display:block;font-family:var(--mono);font-size:.9rem} .readout-grid p{margin:3px 0 0;color:var(--m-soft);font-size:.82rem;line-height:1.4;overflow-wrap:anywhere} .readout-grid p.is-empty{color:#8dbbd1;font-style:italic} -.readout-grid p.is-miss{color:var(--m-flag)} .readout-ledger ol{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none} .readout-ledger li{display:flex;align-items:baseline;gap:7px;padding:6px 9px;border:1px solid #5ca5c7;border-radius:3px;background:#0b2e46;font-family:var(--mono);font-size:.74rem} .readout-ledger li b{color:var(--m-muted)} diff --git a/stack/index.html b/stack/index.html index 960eb38..3d67972 100644 --- a/stack/index.html +++ b/stack/index.html @@ -19,8 +19,8 @@ - - + + How We Build | SyberLabs diff --git a/stack/stack.css b/stack/stack.css index 2276e96..be68a30 100644 --- a/stack/stack.css +++ b/stack/stack.css @@ -1,5 +1,4 @@ /* Reading page on the site's Midnight theme (src/syberlabs.css): colours come from there; only measures live here. */ -.syber-stack .sy-btn { white-space:normal; min-height:48px; padding:12px 20px; line-height:1.4; } .syber-stack :focus-visible { outline-offset:5px; } .syber-stack main section[id] { scroll-margin-top:100px; } .syber-stack main :is(h1,h2,h3) { text-wrap:balance; } diff --git a/staff.css b/staff.css index 8b29a7b..cb4f2fc 100644 --- a/staff.css +++ b/staff.css @@ -32,7 +32,7 @@ .staff-profile:hover { background: #9adfff0a; text-decoration: none; } .staff-profile:focus-visible { outline: 2px solid var(--sy-ice); outline-offset: 3px; } .staff-btn--small.sy-btn { min-height: 44px; padding: 0 12px; font-size: 13px; } -.workspace-app { background: #07131b; background-image: none; font-size: 15px; } +.workspace-app { background: #07131b; font-size: 15px; } .workspace-app > .sy-field-host { display: none; } .workspace-app .staff-header { position: sticky; top: 0; z-index: 60; } .workspace-app .staff-header__in { max-width: none; padding: 14px 28px; } @@ -192,7 +192,6 @@ /* Forms */ .staff-form { display: grid; gap: var(--space-4); max-width: 720px; } .staff-form__row { display: grid; gap: var(--space-4); } -.staff-filter { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); } .staff-keys { display: grid; gap: var(--space-4); } .staff-fieldset { display: grid; gap: var(--space-1); margin: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--sy-rule); border-radius: var(--sy-r-panel); min-width: 0; } .staff-fieldset legend { padding: 0 var(--space-2); } @@ -228,11 +227,9 @@ .portal-app-section, .portal-tools, .portal-team { scroll-margin-top: 24px; } .portal-tools, .portal-team { margin-top: 32px; } .portal-section-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: end; gap: 12px; margin-bottom: 16px; } -.portal-section-head h2, .portal-app-section > h2, .portal-team > h2 { +.portal-section-head h2 { color: var(--sy-vellum); font: 500 20px/1.3 var(--sy-sans); letter-spacing: -.02em; margin: 0; } -.portal-app-section > h2, .portal-team > h2 { margin-bottom: 16px; } -.portal-section-head > p { color: var(--sy-mist); font-size: 14px; margin: 0; } .portal-apps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } .portal-apps a { --app-accent: #9de8ff; position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 170px; @@ -246,7 +243,6 @@ .portal-app-art--reader { color: #9de8ff; background: radial-gradient(ellipse at 50% 80%, #9de8ff1f, #101e27 75%); } .portal-app-art--sketch { color: #e9c3b8; background: radial-gradient(ellipse at 40% 80%, #f1b5a422, #0e1c26 75%); } .portal-app-art--flyspace { color: #a6e3ff; background: radial-gradient(ellipse at 60% 70%, #9adfff2b, #0c1b25 75%); } -.portal-app-art svg { display: block; width: 100%; height: 100%; } .portal-app-copy { display: flex; flex: 1; flex-direction: column; gap: 10px; padding: 20px; } .portal-apps .portal-kicker { color: var(--app-accent); padding-right: 28px; } .portal-apps strong { color: var(--sy-vellum); font-size: 22px; line-height: 1.25; font-weight: 500; letter-spacing: -.025em; } @@ -264,7 +260,7 @@ .portal-card p { margin: 0 0 18px; max-width: 54ch; color: var(--sy-mist); font-size: 14px; line-height: 1.6; } .portal-card__open { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: auto; font-size: 13px; color: var(--portal-cyan); } .portal-card__open > span { font-size: 20px; } -.portal-team .portal-section-head h2, .portal-team > h2 { color: var(--sy-mist); font-size: 18px; } +.portal-team .portal-section-head h2 { color: var(--sy-mist); font-size: 18px; } .portal-card--team { display: grid; grid-template-columns: 24px minmax(0, 1fr) 20px; align-content: start; align-items: center; gap: 6px 14px; padding: 18px 20px; background: #0a1821; } .portal-card--team .portal-card__top { display: contents; } .portal-card--team .portal-card__top svg { grid-column: 1; grid-row: 1; color: var(--sy-mist); width: 20px; height: 20px; } diff --git a/syber-logo.webp b/syber-logo.webp deleted file mode 100644 index 65491de..0000000 Binary files a/syber-logo.webp and /dev/null differ