diff --git a/README.md b/README.md index e07f1ed..6c01a33 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@
-UR LF ✘ UP — Where Minds Meet. Visionary, Builder and Enabler converging on one mark. +UrLife — Where Minds Meet. A cinematic animated hero: VISIONARY, BUILDER and ENABLER converge on a 45-minute session dial, with the YASLOGIST signature. Nine-second seamless loop. + +Still poster: urlife-hero-poster.png · Rendered from repository code by assets/readme/render-readme-assets.mjs · No third-party artwork

UrLife — Where Minds Meet

@@ -11,26 +13,175 @@

- critical path 55.7 kB gzip - 283 specs passing - axe 0 violations 2 runtime dependencies zero UI framework Vite 6 + Node 20.19 or newer

- System Architecture · - Feature Matrix · - Core Workflows · - Tech Stack · - Quick start · - Known gaps + The system · + Capabilities · + Architecture · + Execution · + Ignition · + Deep docs · + Status · + YASLOGIST

---- +

+ +## 01 · The system + +UrLife is a single-page marketing site with a progressively loaded member console in the +same document. It is written in vanilla ES2022 modules, with no UI framework and no +application server. The browser talks to Supabase (Postgres, GoTrue auth, Realtime, +Storage) directly, and Postgres row-level security is the authorisation boundary. + +The product workflow is the one the site describes. Members submit a **structured idea +brief**. A deterministic **matching engine** scores compatibility across the three roles +the product is built around, **Visionary**, **Builder** and **Enabler** (the Postgres enum +`user_role_type`). The process ends in one **45-minute private session with the UrLife +team**, where partnership terms are discussed. + +**How to read the hero** + +| Element on the instrument | What it represents | +| :-- | :-- | +| Three role nodes on the orbit | The roles the platform matches across | +| Light moving along the orbit | The complementary pairings the engine recognises: Visionary ↔ Builder and Visionary ↔ Enabler | +| Packets flowing into the core | Each role's signal converging on the same mark | +| 45 ticks and the sweep arm | The 45-minute session, one tick per minute, turning once per loop | + +## 02 · Core capabilities + +**Legend** — ● implemented and covered by specs · ◐ implemented with a named limit · ○ client-side simulation, no backend contract + +| Capability | Where | State | +| :-- | :-- | :-: | +| Structured idea submission with field-level validation | `src/ideas.js` · `nexus-schema.sql` | ● | +| Role selection constrained to the Postgres enum | `src/validators.js` | ● | +| Explainable matching across six weighted axes | `src/matchingEngine.js` | ● | +| Recommendations dashboard over public ideas and interests | `src/dashboard.js` | ● | +| Email + password and magic-link sign-in, PKCE session | `src/auth.js` · `src/lib/supabase.js` | ● | +| Realtime interest and idea updates | `supabase.channel('nexus')` | ◐ | +| Terms workspace (local draft, no save, send or escrow) | `src/components/DealRoom.js` | ○ | +| Verification badge tiers (presentational) | `src/components/VerificationBadge.js` | ○ | +| Bilingual EN ⇄ AR interface with full RTL inversion | `src/app/i18n.js` | ● | +| Degraded mode when no backend credentials are configured | `src/lib/supabase.js` | ● | +| Service worker v3 with offline fallback document | `public/sw.js` | ● | +| Build-enforced size budgets and inline-style guard | `vite.config.js` | ● | +| Accessibility checks (axe-core) and WCAG AA contrast audit in CI | `tests/a11y` · `scripts/audit-contrast.mjs` | ● | + +## 03 · Engineering architecture + +- **One module entry.** `index.html` loads `src/main.js`, which runs one ordered, idempotent + boot sequence in `src/app/boot.js`. Dependencies are checked by tests, not by convention. +- **The backend is lazy.** `src/app/account.js` and every Supabase-backed module load on a + magic-link callback, the first call-to-action intent, or idle time. A visitor who only + reads the page downloads no backend code. +- **Authorisation lives in Postgres.** RLS is enabled and forced on the tables defined in + `nexus-schema.sql`. The anon key is public by design, so RLS is the only real boundary. +- **The visual field degrades by capability.** WebGL2, then Canvas2D, then no field at all. + Content never depends on the animation. + +```mermaid +flowchart LR + subgraph Browser["Browser · static origin · no application server"] + direction TB + HTML["index.html
one document"] --> MAIN["src/main.js
single module entry"] + MAIN --> BOOT["src/app/boot.js
ordered, idempotent boot"] + BOOT --> CORE["i18n · modal · motion · visual/field.js"] + BOOT -->|"magic link · first CTA intent · idle"| ACCT["src/app/account.js
lazy backend wiring"] + ACCT --> FEAT["auth · ideas · settings"] + ACCT --> DASH["dashboard.js"] + DASH --> MATCH["matchingEngine.js
explainable scoring"] + ACCT --> SBC["src/lib/supabase.js
one client"] + end + SBC -->|"REST · GoTrue · Realtime · Storage"| SUPA[("Supabase Postgres
RLS enabled and forced")] + HTML -->|"iframe · CSP frame-src allow-list"| CAL["Cal.com booking embed"] + HTML -.->|"production + secure context"| SW["public/sw.js"] + + classDef lazy fill:#1a1a1a,stroke:#D4AF37,color:#f5f5f5; + classDef core fill:#111,stroke:#888,color:#eee; + class ACCT,FEAT,DASH,MATCH,SBC lazy; + class HTML,MAIN,BOOT,CORE core; +``` + +Full module graph, trust boundary and data plane: [§1](#1--system-architecture). + +## 04 · Execution intelligence + +**Cold start.** The sequence below is what runs before the first interaction. The +backend is not part of it. + +1. The browser parses `index.html`: CSP, preconnect and manifest. +2. `src/main.js` calls `boot()`, which sets data-tier and error reporting, then network state. +3. The i18n, modal, motion and pointer modules initialise in order. +4. The field renders through the capability ladder, and boot dispatches `urlife:ready`. +5. Pointer, focus and click intent are armed. The member console loads only when a visitor + acts on it, and a click that arrives while the module is still loading is replayed + once the module is ready. + +**Explainable matching.** `src/matchingEngine.js` is deterministic. Each axis returns a +score, a confidence and a human-readable note, so the dashboard can explain every ranking. +The default weights are: + +| Axis | Default weight | What it measures | +| :-- | --: | :-- | +| Skills | 0.34 | Fuzzy overlap between the two skill sets | +| Industry | 0.20 | Sector alignment | +| Role | 0.16 | Complementarity of the two roles | +| Data quality | 0.12 | Confidence when fields are sparse | +| Capital | 0.10 | Capital-pool fit | +| Reputation | 0.08 | Reputation signal | + +Missing fields lower confidence rather than breaking the conversion path. Weights can be +overridden through `createWeightProfile`, which rejects negative values and totals above 1.0. + +## 05 · Ignition + +Requires Node.js 20.19 or newer. + +```bash +npm ci +npm run dev # Vite, bound to 0.0.0.0:5173 +``` + +No `.env` is required. Without Supabase credentials the site runs in degraded mode: everything +renders and animates, and only authenticated flows report that they are unavailable. + +```bash +npm test # unit, flow, contract and accessibility specs +npm run build # production build; fails on a size or inline-style budget breach +npm run verify # lint, tests, build, bundle measurement, contrast and dependency audit +node assets/readme/render-readme-assets.mjs # regenerate the README hero, divider and signature +``` + +The full command reference is in [§7](#7--quick-start). + +## 06 · Deep system access + +The complete engineering specification follows. Section numbers are unchanged from earlier +revisions, so existing links keep working. + +| Section | Topic | +| :-- | :-- | +| [§1](#1--system-architecture) | System architecture, module ownership, data plane, trust boundary | +| [§2](#2--feature-matrix) | Feature matrix with per-capability state | +| [§3](#3--core-workflows) | Cold start, intent capture, authentication, matching, degraded mode, OG asset pipeline | +| [§4](#4--tech-stack) | Stack, versions and dependency budget | +| [§5](#5--performance-envelope) | Payload, simulation and draw-call measurements | +| [§6](#6--quality-gates) | CI quality gates and thresholds | +| [§7](#7--quick-start) | Quick start and full command reference | +| [§8](#8--repository-map) | Repository map | +| [§9](#9--known-gaps) | Known gaps and cheapest resolutions | +| [§10](#10--security-licence-provenance) | Security, licence and provenance | + +

## Abstract @@ -941,7 +1092,48 @@ measured), [`docs/MIGRATIONS.md`](docs/MIGRATIONS.md). + +

+ +## 07 · Engineering status + +Status is stated by evidence, not intent. The detailed matrix is in [§2](#2--feature-matrix); +open items and their cheapest resolutions are in [§9](#9--known-gaps). + +| Status | What it covers | +| :-- | :-- | +| **Implemented and covered** | Auth flows, idea submission, ideas feed, explainable matching, the dashboard, settings and avatar upload, bilingual interface, dialog stack, service worker, build and size budgets, contrast and accessibility checks | +| **Implemented with a named limit** | Realtime updates (`postgres_changes`, untested against a live Supabase project), custom cursor (fine pointer only), Capacitor shells (not exercised in CI), no-JavaScript fallback (content renders, console unavailable) | +| **Client-side simulation by design** | Terms workspace (no save, send, signature, funding or escrow), verification badge tiers (no issuing authority) | +| **Unverified in this repository** | Real-browser frame rate, LCP and CLS; native Capacitor builds; the booking link's current availability (see [§9](#9--known-gaps)) | + +The hero, divider and signature are generated from code in `assets/readme/`. The hero GIF +was decoded with an independent decoder: all 180 frames are present, and the loop seam +differs by about the same amount as ordinary consecutive frames. Representative frames were +inspected visually. GitHub's own renderer was not available here, so its display is unverified. + +## 08 · YASLOGIST + +

+ YASLOGIST — www.yaslogist.com. Animated closing signature with a light sweep across the wordmark. +

+ +

+ Still signature +

+ +**UrLife** is presented under the **YASLOGIST** creative signature. The product name stays +the primary identity; the YASLOGIST signature sits beside it in the hero, in this section and +in the closing line below. The repository contains no official YASLOGIST logo file, so the +wordmark is typeset from the same stroke geometry as the UrLife product mark, and no logo +has been invented. + +| Link | Destination | +| :-- | :-- | +| YASLOGIST | [www.yaslogist.com](https://www.yaslogist.com) | +| UrLife | [urlifeisup.com](https://urlifeisup.com/) | +

-UR LF ✘ UP · urlifeisup.com · Where Minds Meet +UR LF ✘ UP · Where Minds Meet · a YASLOGIST signature · www.yaslogist.com
diff --git a/assets/readme/divider.gif b/assets/readme/divider.gif new file mode 100644 index 0000000..b6c5e60 Binary files /dev/null and b/assets/readme/divider.gif differ diff --git a/assets/readme/render-readme-assets.mjs b/assets/readme/render-readme-assets.mjs new file mode 100644 index 0000000..a1ed807 --- /dev/null +++ b/assets/readme/render-readme-assets.mjs @@ -0,0 +1,710 @@ +#!/usr/bin/env node +/** + * render-readme-assets.mjs — renders the README's cinematic visual system from code. + * + * Produces (all deterministic, no network, no runtime dependencies): + * assets/readme/urlife-hero.gif 1920×720 animated hero, 9 s seamless loop + * assets/readme/urlife-hero-poster.png static still of the hero (fallback / reduced motion) + * assets/readme/divider.gif luminous section divider, 3.6 s loop + * assets/readme/yaslogist-signature.gif closing YASLOGIST signature, 2.9 s loop + * assets/readme/yaslogist-signature.png static still of the closing signature + * + * Built on the same zero-dependency pipeline as scripts/generate-og.mjs: + * linear-light compositing, SDF anti-aliasing, a geometric monoline typeface + * authored as stroke geometry (so no third-party font is vendored), brand-seeded + * median-cut palette, ordered dithering, and a temporal-diff GIF encoder. + * + * ── THE CONCEPT ────────────────────────────────────────────────────────────── + * UrLife is a collaboration platform that brings three roles together: + * VISIONARY, BUILDER and ENABLER. The hero stages that workflow as an + * instrument: the two complementary pairings the matching engine recognises + * (Visionary ↔ Builder, Visionary ↔ Enabler) flow along a matching orbit, + * every role streams its signal into the core, and a 45-minute dial — the + * length of the closed session — sweeps once per loop. + * + * ── THE LOOP ───────────────────────────────────────────────────────────────── + * Every animated quantity is a periodic function of T ∈ [0, 1) built from + * integer harmonics of 2π·T, so frame N−1 flows into frame 0 with no seam. + * Only the instrument moves; the typography and the field are rendered once + * and never re-encoded, which is what keeps the file small. + * + * Usage: + * node assets/readme/render-readme-assets.mjs [--only=hero|divider|signature] + * [--frames=180] [--poster=70] [--out=assets/readme] + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +import { BRAND, hexToBytes } from '../../scripts/lib/brand.mjs'; +import { encodePNGFromRGB } from '../../scripts/lib/png.mjs'; +import { buildPalette, createMapper, quantizeFrame, encodeAnimatedGIF } from '../../scripts/lib/gif.mjs'; +import { layoutText, measureText } from '../../scripts/lib/type.mjs'; +import { Mask, Surface, color, mixColor, mulberry32, paintMask, resolve, smoothstep } from '../../scripts/lib/surface.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const TAU = Math.PI * 2; +const DEG = Math.PI / 180; + +const argv = Object.fromEntries( + process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, '').split('='); + return [k, v ?? true]; + }) +); + +/* ── Canvas ───────────────────────────────────────────────────────────────── */ + +const W = 1920; +const H = 720; +/** Supersample factor. Hairlines and the brand mark are sub-pixel at 1×. */ +const SS = 2; +const FRAMES = Number(argv.frames ?? 180); +/** 5 cs per frame × 180 frames = 9.0 s. 4 cs is the floor browsers honour. */ +const DELAY_CS = 5; +const DITHER = 3; + +const S = (v) => v * SS; + +/* ── Palette: the UrLife obsidian + gold system, plus one cool structural accent ── */ + +const C = { + top: color('#0E0E13'), + bottom: color('#040405'), + gold: color(BRAND.gold), + goldBright: color(BRAND.goldBright), + goldDeep: color(BRAND.goldDeep), + ember: color(BRAND.ember), + bone: color(BRAND.bone), + smoke: color(BRAND.smoke), + filament: color(BRAND.filament), + /** Structural accent: coordinate ticks and the ambient field only. */ + cyan: color('#7FD3E0'), +}; + +const PINNED = [ + hexToBytes('#0E0E13'), + hexToBytes('#040405'), + hexToBytes(BRAND.gold), + hexToBytes(BRAND.goldBright), + hexToBytes(BRAND.goldDeep), + hexToBytes(BRAND.ember), + hexToBytes(BRAND.bone), + hexToBytes(BRAND.smoke), + hexToBytes(BRAND.filament), + hexToBytes('#7FD3E0'), + [255, 255, 255], + [0, 0, 0], +]; + +/* ── Geometry ─────────────────────────────────────────────────────────────── */ + +/** The instrument. Everything dynamic lives inside the 660 px box around it. */ +const HUB = { x: 1500, y: 360 }; +const RING = { coord: 320, roles: 262, dash: 130, mid: 150, dialIn: 196, dialOut: 214, core: 40 }; + +const polar = (cx, cy, angleDeg, r) => [cx + Math.cos(angleDeg * DEG) * r, cy - Math.sin(angleDeg * DEG) * r]; + +/** Role nodes sit on the matching orbit. Angles are math-convention (CCW). */ +const ROLES = [ + { key: 'visionary', label: 'VISIONARY', angle: 90, place: 'right' }, + { key: 'builder', label: 'BUILDER', angle: 210, place: 'below' }, + { key: 'enabler', label: 'ENABLER', angle: 330, place: 'below' }, +].map((r, i) => { + const [x, y] = polar(HUB.x, HUB.y, r.angle, RING.roles); + return { ...r, x, y, i, bow: i === 1 ? 0.07 : -0.07 }; +}); + +/** + * Convergence filaments: a bowed quadratic from each role to the core. + * Packets ride these curves. Points are ordered node → core. + */ +function convergenceCurve(role) { + const end = polar(HUB.x, HUB.y, Math.atan2(HUB.y - role.y, role.x - HUB.x) / DEG, RING.core); + const dx = end[0] - role.x; + const dy = end[1] - role.y; + const len = Math.hypot(dx, dy); + const nx = -dy / len; + const ny = dx / len; + const cx = (role.x + end[0]) / 2 + nx * len * role.bow; + const cy = (role.y + end[1]) / 2 + ny * len * role.bow; + const pts = []; + for (let i = 0; i <= 96; i++) { + const t = i / 96; + const u = 1 - t; + pts.push([u * u * role.x + 2 * u * t * cx + t * t * end[0], u * u * role.y + 2 * u * t * cy + t * t * end[1]]); + } + return pts; +} + +/** + * Packet schedule. Each role departs three packets per loop, staggered so the + * nine arrivals at the core land on the nine equal slots j/9. A packet takes + * two thirds of the loop to travel, so two are in flight per filament. + */ +const PACKET_TRAVEL = 2 / 3; + +/** Matching arcs along the orbit, from Visionary to each complementary role. */ +const MATCH_ARCS = [ + { from: 90, to: 210, dir: 1 }, + { from: 90, to: -30, dir: -1 }, +]; + +/* ── Helpers ──────────────────────────────────────────────────────────────── */ + +const wrap01 = (x) => x - Math.floor(x); +const wrapSigned = (x) => x - Math.round(x); +const wrapDeg = (d) => ((d % 360) + 360) % 360; +const gauss = (d, sigma) => Math.exp(-(d * d) / (2 * sigma * sigma)); + +/** Arrival instants at the core: nine equal slots across the loop. */ +const ARRIVALS = Array.from({ length: 9 }, (_, j) => j / 9); +/** Periodic core flare: sum of Gaussian arrival pulses, wrapped into the loop. */ +function coreFlare(T) { + let f = 0; + for (const t of ARRIVALS) f += gauss(wrapSigned(T - t), 0.011); + return Math.min(1.6, f); +} + +/** Activation envelope: a single slow cycle, brightest at Act IV (brand resolution). */ +const activation = (T) => 0.72 + 0.28 * Math.cos(TAU * (T - 0.85)); + +/* ── Static scene (built once) ────────────────────────────────────────────── */ + +function buildScene() { + const rand = mulberry32(0x0c0ffee); + + // Ambient field: outside the coordinate ring, never beneath the type column. + const ambient = []; + let guard = 0; + while (ambient.length < 62 && guard++ < 40000) { + const x = 1000 + rand() * 890; + const y = 40 + rand() * 646; + if (Math.hypot(x - HUB.x, y - HUB.y) < RING.coord + 26) continue; + if (x > 1640 && y < 130) continue; // keep the corner caption clear + if (ambient.some((n) => Math.hypot(n.x - x, n.y - y) < 56)) continue; + const z = rand(); // depth: far nodes are smaller and dimmer + ambient.push({ x, y, z, r: 1.0 + 1.6 * z }); + } + + // Constellation links between near neighbours, kept clear of the instrument. + const links = []; + const seen = new Set(); + ambient.forEach((a, i) => { + ambient + .map((b, j) => ({ j, d: Math.hypot(a.x - b.x, a.y - b.y) })) + .filter((o) => o.j !== i && o.d < 190) + .sort((p, q) => p.d - q.d) + .slice(0, 2) + .forEach(({ j }) => { + const key = i < j ? `${i}-${j}` : `${j}-${i}`; + if (seen.has(key)) return; + seen.add(key); + const b = ambient[j]; + let clear = true; + for (let t = 0; t <= 1; t += 0.05) { + const px = a.x + (b.x - a.x) * t; + const py = a.y + (b.y - a.y) * t; + if (Math.hypot(px - HUB.x, py - HUB.y) < RING.coord + 14) clear = false; + } + if (clear) links.push({ a, b, z: (a.z + b.z) / 2 }); + }); + }); + + // Bokeh discs in the field, depth-graded and kept off the instrument. + const bokeh = []; + guard = 0; + while (bokeh.length < 14 && guard++ < 8000) { + const x = 1010 + rand() * 860; + const y = 60 + rand() * 600; + if (Math.hypot(x - HUB.x, y - HUB.y) < RING.coord + 60) continue; + bokeh.push({ x, y, r: 26 + rand() * 60, warm: rand() > 0.5 }); + } + + return { ambient, links, bokeh }; +} + +const SCENE = buildScene(); + +/** Static typography. Built once; the glyphs are authored geometry, not a font file. */ +function textMask(text, opts) { + const m = new Mask(W * SS, H * SS); + m.text(layoutText(text, opts)); + return m; +} + +function paintStaticText(s, text, opts, rgb) { + paintMask(s, textMask(text, opts), () => rgb, { additive: false }); +} + +/** Arguments are in supersampled px. The ✘ is two strokes; one carries ember, as in the OG card. */ +function paintBrandMark(s, cx, cy, size, weight) { + const probe = layoutText('\u2718', { x: 0, y: 0, size, weight, tracking: 0 }); + const { runs } = layoutText('\u2718', { x: cx - probe.width / 2, y: cy + size / 2, size, weight, tracking: 0 }); + runs.forEach((run, idx) => { + const m = new Mask(W * SS, H * SS); + m.polyline(run.points, run.width); + const rgb = idx === 0 ? mixColor(C.gold, C.goldBright, 0.25) : C.ember; + paintMask(s, m, () => rgb, { additive: false }); + }); +} + +/** Backdrop: everything that never moves. Rendered once, copied per frame. */ +function buildBackdrop(scene) { + const s = new Surface(W * SS, H * SS); + + // Obsidian gradient, then dimensional haze. No flat black. + s.paint((x, y) => mixColor(C.top, C.bottom, smoothstep(0, (H * SS) * 0.95, y))); + s.addRadial(S(HUB.x), S(HUB.y), S(860), C.gold, 0.034, 2.6); + s.addRadial(S(HUB.x), S(HUB.y), S(330), C.goldDeep, 0.03, 2.2); + s.addRadial(S(170), S(640), S(620), C.cyan, 0.0065, 2.2); + s.addRadial(S(1880), S(40), S(460), C.cyan, 0.0075, 2.2); + + // Bokeh: out-of-focus depth in the field. + for (const b of scene.bokeh) { + s.addRadial(S(b.x), S(b.y), S(b.r), b.warm ? C.gold : C.cyan, b.warm ? 0.012 : 0.009, 3.2); + } + + // Volumetric shafts falling into the instrument. + s.glowLine(S(1260), S(-30), S(1470), S(300), S(70), C.gold, 0.012, 0.0); + s.glowLine(S(1500), S(-30), S(1500), S(290), S(60), C.goldBright, 0.009, 0.0); + s.glowLine(S(1760), S(-30), S(1530), S(300), S(70), C.gold, 0.01, 0.0); + + // Ambient constellation: the product's ambient field, depth-graded. + for (const l of scene.links) { + s.segment(S(l.a.x), S(l.a.y), S(l.b.x), S(l.b.y), S(1.0), C.cyan, 0.11 + 0.12 * l.z, true); + } + for (const n of scene.ambient) { + s.spark(S(n.x), S(n.y), S(n.r * 3.2), mixColor(C.cyan, C.bone, 0.3), 0.16 + 0.42 * n.z, 0.22); + } + + // Coordinate frame: precision, not decoration. + s.circle(S(HUB.x), S(HUB.y), S(RING.coord), S(1.1), C.cyan, 0.3, false); + s.circle(S(HUB.x), S(HUB.y), S(RING.mid), S(1.4), C.gold, 0.2, false); + // Mid ring: lit by one key light at 132°, so it reads as a machined edge. + for (let a = 0; a < 360; a += 3) { + const k = 0.18 + 0.82 * Math.max(0, Math.cos((a - 132) * DEG)) ** 1.6; + s.circle(S(HUB.x), S(HUB.y), S(RING.mid), S(1.6), C.goldBright, 0.55 * k, true, a, a + 3.2); + } + // Dial base ticks: forty-five of them, one per minute of the closed session. + for (let m = 0; m < 45; m++) { + const major = m % 5 === 0; + const [x0, y0] = polar(HUB.x, HUB.y, 90 - 8 * m, major ? RING.dialIn - 8 : RING.dialIn); + const [x1, y1] = polar(HUB.x, HUB.y, 90 - 8 * m, RING.dialOut); + s.segment(S(x0), S(y0), S(x1), S(y1), S(major ? 1.6 : 1.0), C.smoke, major ? 0.42 : 0.2, false); + } + // Matching orbit base: the complementary pairings the engine recognises. + for (const arc of MATCH_ARCS) { + const pts = []; + for (let a = arc.from; arc.dir > 0 ? a <= arc.to : a >= arc.to; a += arc.dir * 1.5) { + const [x, y] = polar(HUB.x, HUB.y, a, RING.roles); + pts.push([S(x), S(y)]); + } + s.polyline(pts, S(1.0), C.gold, 0.22, true); + } + // Convergence filaments, faint at rest. + for (const role of ROLES) { + const pts = convergenceCurve(role).map(([x, y]) => [S(x), S(y)]); + s.polyline(pts, S(1.0), C.filament, 0.16, true); + } + + // Frame: a hairline inset with four technical corner brackets. + const inset = 26; + const frameRgb = mixColor(C.gold, C.bone, 0.2); + s.segment(S(inset), S(inset), S(W - inset), S(inset), S(1), frameRgb, 0.14); + s.segment(S(inset), S(H - inset), S(W - inset), S(H - inset), S(1), frameRgb, 0.14); + s.segment(S(inset), S(inset), S(inset), S(H - inset), S(1), frameRgb, 0.14); + s.segment(S(W - inset), S(inset), S(W - inset), S(H - inset), S(1), frameRgb, 0.14); + const L = 44; + for (const [x, y, dx, dy] of [ + [inset, inset, 1, 0], + [inset, inset, 0, 1], + [W - inset, inset, -1, 0], + [W - inset, inset, 0, 1], + [inset, H - inset, 1, 0], + [inset, H - inset, 0, -1], + [W - inset, H - inset, -1, 0], + [W - inset, H - inset, 0, -1], + ]) { + s.segment(S(x), S(y), S(x + dx * L), S(y + dy * L), S(2.2), C.gold, 0.9); + } + + // ── Typography: the product, the brief, and the signature ── + // Eyebrow: the product mark as the brand carries it everywhere else. + paintStaticText( + s, + 'UR LF \u2718 UP', + { x: S(120), y: S(150), size: S(22), tracking: S(10), weight: S(2.4) }, + mixColor(C.gold, C.goldBright, 0.3) + ); + + // Headline: lit, not filled. Near-white at the cap line, warm bone at the baseline. + const headLines = [ + { text: 'WHERE', y: 318 }, + { text: 'MINDS MEET', y: 444 }, + ]; + const hTop = S(318 - 118); + const hBot = S(444 + 4); + const headMask = new Mask(W * SS, H * SS); + for (const line of headLines) { + headMask.text(layoutText(line.text, { x: S(120), y: S(line.y), size: S(118), tracking: S(8), weight: S(9) })); + } + paintMask( + s, + headMask, + (x, y) => { + const t = smoothstep(hTop, hBot, y); + const shade = mixColor(mixColor(C.filament, C.bone, 0.45), mixColor(C.bone, C.gold, 0.2), t); + return shade; + }, + { additive: false } + ); + + // Brief line. A rule, then the two sentences the product actually promises. + s.segment(S(120), S(490), S(270), S(490), S(3), C.gold, 1.0); + paintStaticText( + s, + 'A PROJECT COLLABORATION PLATFORM FOR THE ARAB WORLD.', + { x: S(120), y: S(548), size: S(22), tracking: S(2.6), weight: S(1.9) }, + mixColor(C.bone, C.smoke, 0.25) + ); + paintStaticText( + s, + 'STRUCTURED BRIEF \u00B7 EXPLAINABLE MATCHING \u00B7 45-MINUTE CLOSED MEETING', + { x: S(120), y: S(590), size: S(17), tracking: S(3.1), weight: S(1.6) }, + C.smoke + ); + + // Signature, lower left. The YASLOGIST wordmark is lettered from the same geometry as the product mark. + s.segment(S(120), S(624), S(1000), S(624), S(1), C.gold, 0.22); + const sigOpts = { x: S(120), y: S(684), size: S(40), tracking: S(14), weight: S(4.2) }; + paintStaticText(s, 'YASLOGIST', sigOpts, C.bone); + const sigW = measureText('YASLOGIST', { size: 40, tracking: 14 }); + s.segment(S(120 + sigW + 30), S(650), S(120 + sigW + 30), S(684), S(1.2), C.gold, 0.7); + paintStaticText( + s, + 'WWW.YASLOGIST.COM', + { x: S(120 + sigW + 56), y: S(684), size: S(16), tracking: S(4), weight: S(1.7) }, + mixColor(C.gold, C.goldBright, 0.3) + ); + + // Top-right caption over the instrument, outside the coordinate ring. + paintStaticText( + s, + 'CONVERGENCE', + { x: S(1860), y: S(84), size: S(14), tracking: S(5), weight: S(1.5), align: 'right' }, + C.smoke + ); + + // Role labels, placed to clear the ring and each other. + for (const n of ROLES) { + const size = 17; + const tracking = 4.2; + const w = measureText(n.label, { size, tracking }); + const pos = n.place === 'below' ? { x: S(n.x - w / 2), y: S(n.y + 42) } : { x: S(n.x + 22), y: S(n.y + 6) }; + paintStaticText(s, n.label, { ...pos, size: S(size), tracking: S(tracking), weight: S(1.6) }, mixColor(C.smoke, C.goldBright, 0.55)); + } + + // Role nodes: a fixed ring of light. Their brightness is animated; their geometry is not. + for (const n of ROLES) { + s.circle(S(n.x), S(n.y), S(9), S(1.4), C.goldBright, 0.7, true); + s.spark(S(n.x), S(n.y), S(20), C.gold, 0.55, 0.3); + } + + // The brand mark sits at the core; it is painted before the bloom so it glows. + paintBrandMark(s, S(HUB.x), S(HUB.y), S(92), S(5.4)); + s.bloom({ threshold: 0.55, radius: 20, intensity: 0.3 }); + return s; +} + +/* ── Dynamic instrument ───────────────────────────────────────────────────── */ + +/** + * Draw everything that moves. This is the only region of the GIF that changes + * between frames, so it is kept inside a single 660 px box around the hub. + */ +function drawInstrument(s, T) { + const A = activation(T); + const cx = S(HUB.x); + const cy = S(HUB.y); + + // 1) Outer coordinate ticks rotate one 30° step per loop. The pattern repeats every 30°, so the loop closes. + const rot = 30 * T; + for (let i = 0; i < 180; i++) { + const a = i * 2 + rot; + const major = i % 15 === 0; + const r0 = RING.coord - (major ? 16 : 5); + const [x0, y0] = polar(HUB.x, HUB.y, a, r0); + const [x1, y1] = polar(HUB.x, HUB.y, a, RING.coord); + s.segment(S(x0), S(y0), S(x1), S(y1), S(major ? 1.8 : 1.0), major ? C.gold : C.cyan, (major ? 0.9 : 0.4) * A, true); + } + + // 2) Dashed inner ring counter-rotates one full turn per loop. The 10° pattern divides 360°, so it closes too. + const dashRot = -360 * T; + for (let k = 0; k < 36; k++) { + const from = k * 10 + dashRot; + s.circle(cx, cy, S(RING.dash), S(2.0), C.goldBright, 0.5 * A, true, from, from + 6); + } + + // 3) The 45-minute dial. The sweep arm turns once per loop, clockwise. + const sweep = 90 - 360 * T; + // Trail first, behind the head. + for (let k = 0; k < 56; k++) { + const a = sweep + k * 2; + const alpha = 0.55 * (1 - k / 56) ** 2.4 * A; + const [x0, y0] = polar(HUB.x, HUB.y, a, RING.mid + 2); + const [x1, y1] = polar(HUB.x, HUB.y, a, RING.dialIn - 3); + s.segment(S(x0), S(y0), S(x1), S(y1), S(3.2), C.gold, alpha, true); + } + // Ticks light as the head passes, then fade out behind it. + for (let m = 0; m < 45; m++) { + const tickAngle = 90 - 8 * m; + const behind = wrapDeg(tickAngle - sweep); + const lit = Math.exp(-behind / 22); + const major = m % 5 === 0; + const [x0, y0] = polar(HUB.x, HUB.y, tickAngle, major ? RING.dialIn - 8 : RING.dialIn); + const [x1, y1] = polar(HUB.x, HUB.y, tickAngle, RING.dialOut); + s.segment(S(x0), S(y0), S(x1), S(y1), S(major ? 2.2 : 1.6), C.goldBright, 0.12 + 0.88 * lit, true); + } + // Head. + const [hx0, hy0] = polar(HUB.x, HUB.y, sweep, RING.mid - 4); + const [hx1, hy1] = polar(HUB.x, HUB.y, sweep, RING.dialOut + 6); + s.segment(S(hx0), S(hy0), S(hx1), S(hy1), S(2.6), C.filament, 0.95, true); + const [tipX, tipY] = polar(HUB.x, HUB.y, sweep, RING.dialOut); + s.spark(S(tipX), S(tipY), S(16), C.goldBright, 0.55 * A, 0.3); + + // 4) Matching orbit: light moves from Visionary toward each complementary role. + for (const arc of MATCH_ARCS) { + const span = Math.abs(arc.to - arc.from); + for (let u = 0; u < span; u += 1.5) { + const a = arc.from + arc.dir * u; + const along = u * RING.roles * DEG; + // Ten dash periods per loop: an integer, so the flow closes on itself. + const phase = wrap01(along / 46 - 10 * T); + if (phase > 0.34) continue; + const fade = 1 - smoothstep(0.24, 0.34, phase); + const [x0, y0] = polar(HUB.x, HUB.y, a, RING.roles); + const [x1, y1] = polar(HUB.x, HUB.y, a + arc.dir * 1.5, RING.roles); + s.segment(S(x0), S(y0), S(x1), S(y1), S(3.0), C.goldBright, 0.95 * fade * A, true); + } + } + + // 5) Role nodes breathe, three cycles per loop, each out of phase. + for (const n of ROLES) { + const breath = 0.5 + 0.5 * Math.sin(TAU * (3 * T + n.i / 3)); + s.spark(S(n.x), S(n.y), S(44), C.gold, (0.1 + 0.12 * breath) * A, 0.12); + s.spark(S(n.x), S(n.y), S(10), C.goldBright, 0.35 + 0.35 * breath, 0.35); + } + + // 6) Convergence packets travel node → core along each filament. + for (const n of ROLES) { + const pts = convergenceCurve(n); + const last = pts.length - 1; + for (let k = 0; k < 3; k++) { + const departure = k / 3 + n.i / 9; + const dt = wrap01(T - departure); + if (dt >= PACKET_TRAVEL) continue; + const p = dt / PACKET_TRAVEL; + const fade = smoothstep(0.0, 0.08, p) * (1 - smoothstep(0.88, 1.0, p)); + if (fade <= 0.01) continue; + const head = Math.min(last, Math.floor(p * last)); + // Tail: fading sparks behind the head. + for (let t = 0; t < 9; t++) { + const idx = head - t * 2; + if (idx < 0) break; + const [x, y] = pts[idx]; + s.spark(S(x), S(y), S(7 - t * 0.5), C.filament, (0.5 - t * 0.05) * fade * A, 0.3); + } + const [hx, hy] = pts[head]; + s.spark(S(hx), S(hy), S(9), C.filament, 0.9 * fade, 0.35); + } + } + + // 7) Core: arrivals flare the mark. Between arrivals, the brand rests at its activation level. + const F = coreFlare(T); + s.addRadial(cx, cy, S(70 + 26 * F), C.goldBright, (0.09 + 0.2 * F) * A, 2.4); + s.spark(cx, cy, S(24 + 8 * F), C.goldBright, (0.2 + 0.5 * F) * A, 0.3); +} + +/* ── Frame loop ───────────────────────────────────────────────────────────── */ + +function renderHero() { + const started = Date.now(); + const outDir = resolvePath(HERE, String(argv.out ?? '.')); + mkdirSync(outDir, { recursive: true }); + + process.stdout.write(` hero: composing ${FRAMES} frames at ${W}×${H} (${SS}× supersampled)…\n`); + const backdrop = buildBackdrop(SCENE); + const work = new Surface(W * SS, H * SS); + + const rgbFrames = []; + for (let i = 0; i < FRAMES; i++) { + work.copyFrom(backdrop); + drawInstrument(work, i / FRAMES); + rgbFrames.push(resolve(work, SS)); + if ((i + 1) % 30 === 0 || i === FRAMES - 1) { + process.stdout.write(` frame ${String(i + 1).padStart(3)}/${FRAMES}\n`); + } + } + + process.stdout.write(' quantising…\n'); + const { table, size } = buildPalette(rgbFrames, { pinned: PINNED, sampleStride: 2 }); + const nearest = createMapper(table, size); + const indexed = rgbFrames.map((f) => quantizeFrame(f, W, H, nearest, DITHER)); + + process.stdout.write(' encoding…\n'); + const { buffer, stats } = encodeAnimatedGIF({ + width: W, + height: H, + palette: table, + frames: indexed, + delayCs: DELAY_CS, + loop: 0, + }); + + const gifPath = resolvePath(outDir, 'urlife-hero.gif'); + writeFileSync(gifPath, buffer); + + const posterIndex = Math.min(FRAMES - 1, Math.max(0, Number(argv.poster ?? Math.round(FRAMES * 0.36)))); + const png = encodePNGFromRGB(rgbFrames[posterIndex], W, H); + const pngPath = resolvePath(outDir, 'urlife-hero-poster.png'); + writeFileSync(pngPath, png); + + const moved = ((stats.changedPx / stats.totalPx) * 100).toFixed(1); + process.stdout.write( + ` ${(buffer.length / 1024).toFixed(1)} kB urlife-hero.gif ` + + `(${FRAMES} frames · ${(FRAMES * DELAY_CS) / 100}s loop · ${size} colours · ${moved}% of pixels re-encoded)\n` + + ` ${(png.length / 1024).toFixed(1)} kB urlife-hero-poster.png (frame ${posterIndex})\n` + + ` ${((Date.now() - started) / 1000).toFixed(1)}s\n` + ); +} + +/* ── Luminous divider ─────────────────────────────────────────────────────── */ + +function renderDivider() { + const DW = 1920; + const DH = 16; + const frames = 72; + const outDir = resolvePath(HERE, String(argv.out ?? '.')); + mkdirSync(outDir, { recursive: true }); + + const base = new Surface(DW, DH); + base.paint(() => C.bottom); + // Hairline: fades at both ends, so the divider has no hard edges. + const hairY = 8; + for (let x = 0; x < DW; x++) { + const u = x / (DW - 1); + const a = 0.34 * smoothstep(0, 0.14, u) * (1 - smoothstep(0.86, 1, u)); + const i = (hairY * DW + x) * 3; + base.data[i] += C.gold[0] * a; + base.data[i + 1] += C.gold[1] * a; + base.data[i + 2] += C.gold[2] * a; + } + // Four structural nodes on the hairline. + const nodes = [0.2, 0.4, 0.6, 0.8]; + const rgbFrames = []; + const work = new Surface(DW, DH); + for (let f = 0; f < frames; f++) { + const T = f / frames; + work.copyFrom(base); + // A packet travels left to right and lights each node as it passes. + for (let x = 0; x < DW; x++) { + const u = x / DW; + const d = wrapSigned(u - T); + const head = Math.exp(-(d * d) / (2 * 0.0105 * 0.0105)); + const tail = d < 0 ? Math.exp(d / 0.04) * 0.5 : 0; + const k = (head + tail) * 0.85; + const i = (hairY * DW + x) * 3; + work.data[i] += C.goldBright[0] * k; + work.data[i + 1] += C.goldBright[1] * k; + work.data[i + 2] += C.goldBright[2] * k; + } + for (const nx of nodes) { + const pass = gauss(wrapSigned(T - nx), 0.02); + work.spark(nx * DW, hairY, 5, C.goldBright, 0.12 + 0.6 * pass, 0.3); + work.segment(nx * DW - 4, hairY, nx * DW + 4, hairY, 1, C.filament, 0.4 + 0.6 * pass, true); + work.segment(nx * DW, hairY - 4, nx * DW, hairY + 4, 1, C.filament, 0.25 + 0.5 * pass, true); + } + rgbFrames.push(resolve(work, 1)); + } + const { table, size } = buildPalette(rgbFrames, { pinned: PINNED, sampleStride: 2 }); + const nearest = createMapper(table, size); + const indexed = rgbFrames.map((f) => quantizeFrame(f, DW, DH, nearest, DITHER)); + const { buffer } = encodeAnimatedGIF({ width: DW, height: DH, palette: table, frames: indexed, delayCs: 5, loop: 0 }); + writeFileSync(resolvePath(outDir, 'divider.gif'), buffer); + process.stdout.write(` ${(buffer.length / 1024).toFixed(1)} kB divider.gif (${frames} frames · ${(frames * 5) / 100}s loop)\n`); +} + +/* ── Closing signature ────────────────────────────────────────────────────── */ + +function renderSignature() { + const SW = 1200; + const SH = 300; + const frames = 48; + const outDir = resolvePath(HERE, String(argv.out ?? '.')); + mkdirSync(outDir, { recursive: true }); + + const opts = { size: S(84), tracking: S(22), weight: S(9.5) }; + const word = 'YASLOGIST'; + const wordW = measureText(word, { size: 84, tracking: 22 }); + const x0 = SW / 2 - wordW / 2; + + const base = new Surface(SW * SS, SH * SS); + base.paint((x, y) => mixColor(C.top, C.bottom, smoothstep(0, SH * SS * 0.9, y))); + base.addRadial(S(SW / 2), S(SH / 2), S(560), C.gold, 0.022, 2.4); + // Rule above the wordmark. + base.segment(S(SW / 2 - 40), S(72), S(SW / 2 + 40), S(72), S(2.2), C.gold, 1.0); + // The wordmark, lettered from geometry, with a bone-to-warm gradient. + const mask = new Mask(SW * SS, SH * SS); + mask.text(layoutText(word, { x: S(x0), y: S(190), ...opts })); + paintMask(base, mask, (x, y) => mixColor(C.filament, C.bone, smoothstep(S(96), S(200), y)), { additive: false }); + // Address line. + paintStaticText( + base, + 'WWW.YASLOGIST.COM', + { x: S(SW / 2), y: S(246), size: S(16), tracking: S(6), weight: S(1.7), align: 'center' }, + mixColor(C.gold, C.goldBright, 0.3) + ); + + const work = new Surface(SW * SS, SH * SS); + const rgbFrames = []; + for (let f = 0; f < frames; f++) { + const T = f / frames; + work.copyFrom(base); + // Sheen: a narrow band sweeps across the wordmark once per loop, from outside to outside. + const span = wordW + 260; + const sx = x0 - 130 + span * T; + paintMask( + work, + mask, + (x) => { + const d = x / SS - sx; + const k = Math.exp(-(d * d) / 1800); + if (k < 0.01) return null; + return [C.goldBright[0] * k * 1.1, C.goldBright[1] * k, C.goldBright[2] * k * 0.6]; + }, + { additive: true } + ); + rgbFrames.push(resolve(work, SS)); + } + const { table, size } = buildPalette(rgbFrames, { pinned: PINNED, sampleStride: 2 }); + const nearest = createMapper(table, size); + const indexed = rgbFrames.map((f) => quantizeFrame(f, SW, SH, nearest, DITHER)); + const { buffer } = encodeAnimatedGIF({ width: SW, height: SH, palette: table, frames: indexed, delayCs: 6, loop: 0 }); + writeFileSync(resolvePath(outDir, 'yaslogist-signature.gif'), buffer); + const png = encodePNGFromRGB(rgbFrames[0], SW, SH); + writeFileSync(resolvePath(outDir, 'yaslogist-signature.png'), png); + process.stdout.write( + ` ${(buffer.length / 1024).toFixed(1)} kB yaslogist-signature.gif (${frames} frames · ${(frames * 6) / 100}s loop)\n` + + ` ${(png.length / 1024).toFixed(1)} kB yaslogist-signature.png\n` + ); +} + +/* ── Entry ────────────────────────────────────────────────────────────────── */ + +const only = argv.only ?? 'all'; +if (only === 'all' || only === 'hero') renderHero(); +if (only === 'all' || only === 'divider') renderDivider(); +if (only === 'all' || only === 'signature') renderSignature(); diff --git a/assets/readme/urlife-hero-poster.png b/assets/readme/urlife-hero-poster.png new file mode 100644 index 0000000..ecb38a7 Binary files /dev/null and b/assets/readme/urlife-hero-poster.png differ diff --git a/assets/readme/urlife-hero.gif b/assets/readme/urlife-hero.gif new file mode 100644 index 0000000..0057732 Binary files /dev/null and b/assets/readme/urlife-hero.gif differ diff --git a/assets/readme/yaslogist-signature.gif b/assets/readme/yaslogist-signature.gif new file mode 100644 index 0000000..031ab1f Binary files /dev/null and b/assets/readme/yaslogist-signature.gif differ diff --git a/assets/readme/yaslogist-signature.png b/assets/readme/yaslogist-signature.png new file mode 100644 index 0000000..b68b6bf Binary files /dev/null and b/assets/readme/yaslogist-signature.png differ