From 211266570649f89864a247e15830cea211ce2f30 Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:34:20 +0530 Subject: [PATCH 1/5] docs: refresh the README project overview - Add branded header, badges and project principles - Clarify local image processing and link Pixelact Studio --- README.md | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 88e2b36..ca3d31a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,30 @@ -# Hexlode - -[![CI](https://github.com/pixelactstudio/hexlode/actions/workflows/ci.yml/badge.svg)](https://github.com/pixelactstudio/hexlode/actions/workflows/ci.yml) -[![CodeQL](https://github.com/pixelactstudio/hexlode/actions/workflows/codeql.yml/badge.svg)](https://github.com/pixelactstudio/hexlode/actions/workflows/codeql.yml) - -Open-source image processing in the browser: quick tools for converting, compressing, resizing, cropping, -rotating and stripping metadata, and a node-based Studio for batch pipelines. Images never leave your device. +
+ Hexlode +

Hexlode

+

Open-source image processing that runs in your browser.

+
+ +

+ + CI + + + CodeQL + + + Apache-2.0 license + +

+ +Hexlode has quick tools for converting, compressing, resizing, cropping, rotating and stripping +metadata, and a node-based Studio for running many images through the same pipeline. Images are +processed on your device, without uploading them. + +## Principles + +- Process images on the user's device. +- Make the Studio canvas show real work: progress, results and errors. +- Never send image bytes, filenames, thumbnails or metadata to analytics. ## Run locally @@ -57,3 +77,5 @@ engine, [CONTEXT.md](./CONTEXT.md) the vocabulary and [docs/adr/](./docs/adr/) t [Apache License 2.0](./LICENSE). Copyright 2026 Dev Talan. The jSquash codecs keep their own licences, listed in `node_modules/@jsquash/*/LICENSE` and bundled with the app. + +An open-source project by [Pixelact Studio](https://pixelactstudio.com). From 9605c5cba4c6c05fbdae939600f1f2875be626b8 Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:05:20 +0530 Subject: [PATCH 2/5] feat(home): directed Studio scenes, footer wordmark glow and Damn Labs credits (#10) - Direct the Studio section as GSAP scenes started by ScrollTrigger (ADR 0009) - Nest card corners: 12px cards with 4-6px corners inside - Light the footer wordmark under the pointer and credit Damn Labs and Pixelact Studio - Link hexlode.damnlabs.com from the README --- CLAUDE.md | 7 +- README.md | 3 + docs/adr/0009-gsap-scenes-motion-interface.md | 33 + idea.md | 14 +- implementation.md | 5 +- package.json | 2 + pnpm-lock.yaml | 22 + src/features/app-shell/constants.ts | 8 + src/features/app-shell/footer-wordmark.tsx | 63 ++ src/features/app-shell/icon-tile.tsx | 2 +- src/features/app-shell/site-footer.tsx | 81 +- .../home/__tests__/worker-plan.test.ts | 31 + src/features/home/constants.ts | 39 + src/features/home/device-section.tsx | 14 +- src/features/home/hero.tsx | 6 +- src/features/home/motion-kit.tsx | 45 +- src/features/home/scene.ts | 108 +++ src/features/home/studio-bento.tsx | 700 +++++++++++++----- src/features/home/tool-demos.tsx | 38 +- src/features/home/worker-plan.ts | 16 + 20 files changed, 974 insertions(+), 263 deletions(-) create mode 100644 docs/adr/0009-gsap-scenes-motion-interface.md create mode 100644 src/features/app-shell/footer-wordmark.tsx create mode 100644 src/features/home/__tests__/worker-plan.test.ts create mode 100644 src/features/home/constants.ts create mode 100644 src/features/home/scene.ts create mode 100644 src/features/home/worker-plan.ts diff --git a/CLAUDE.md b/CLAUDE.md index c79c421..3a6459e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When the user changes a decision, update the document that owns it in the same c ## Stack -TanStack Start (React 19, Vite, Nitro), TypeScript, React Flow, Astryx with Tailwind, Motion, jSquash +TanStack Start (React 19, Vite, Nitro), TypeScript, React Flow, Astryx with Tailwind, Motion, GSAP, jSquash codecs in Web Workers, OPFS, PostHog and Sentry. Drizzle, PostgreSQL and Better Auth are dormant until cloud work: keep them compiling and build version 1 features without them. @@ -64,6 +64,11 @@ Tailwind utilities such as `bg-surface`, `text-primary` and `rounded-lg`. - Set colours, type and other tokens in `src/features/theme/hexlode-theme.ts`, then run `pnpm theme:build`. Every colour needs a light and a dark value. - Style the Studio canvas with the same tokens and hide the React Flow attribution. +- Nest corners: an inner corner is the outer corner minus the padding between them, such as a + `rounded-lg` (12px) card with 8px padding around `rounded` (4px) images. Astryx maps `rounded-xl` + to the 28px page radius, so keep it off cards. +- Animate interface elements with Motion. Direct home page scenes with GSAP through `useScene` + and the timings in `src/features/home/constants.ts` (ADR 0009). ## Commits diff --git a/README.md b/README.md index ca3d31a..913922c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Hexlode has quick tools for converting, compressing, resizing, cropping, rotatin metadata, and a node-based Studio for running many images through the same pipeline. Images are processed on your device, without uploading them. +Use it at [hexlode.damnlabs.com](https://hexlode.damnlabs.com). Hexlode is made by [Damn Labs](https://damnlabs.com), +a [Pixelact Studio](https://pixelactstudio.com) product. + ## Principles - Process images on the user's device. diff --git a/docs/adr/0009-gsap-scenes-motion-interface.md b/docs/adr/0009-gsap-scenes-motion-interface.md new file mode 100644 index 0000000..7b5429d --- /dev/null +++ b/docs/adr/0009-gsap-scenes-motion-interface.md @@ -0,0 +1,33 @@ +# GSAP for home page scenes, Motion for the interface + +The home page's Studio pictures are directed scenes: one GSAP timeline each, with beats that +follow one another, a pointer that acts them out, and a rest on the last frame before they repeat. +Motion stays for interface motion such as presses, menus, swapping labels, the top bar and the +footer glow. Before this, every picture was a set of Motion loops with their own periods, so +several things moved at different speeds at once and nothing showed cause and effect. + +A scene is built with `useScene` in `src/features/home/scene.ts`. It creates a paused timeline, +lets the scene add its tweens, starts it with ScrollTrigger when the scene scrolls into view after +a delay for its column, and pauses it off screen. With reduced motion it jumps to the scene's +`poster` label and stays there. Timings and easings come from `src/features/home/constants.ts`. +GSAP moves elements; React state that a scene changes, such as a label or a count, is set from +timeline callbacks, and Motion animates the swap. + +## Considered options + +- **Motion only.** It handles interface motion well, but sequencing beats across elements means + chains of timers and state, and it has no timeline to pause, seek or jump to a still frame. +- **Rive.** Its animations are drawn in the Rive editor and need its runtime. Our pictures are + built from the Studio's own node icons and colour tokens, so they stay sharp and follow the + colour mode without extra artwork. Worth another look for illustration or a mascot. + +## Consequences + +- GSAP ships under its own no-charge licence, not an open-source one. It allows use in any + project, Hexlode's Apache 2.0 code included, and all of its plugins are free. +- GSAP and ScrollTrigger load with the home page only. Tool pages and the Studio do not pay for + them. +- A tween that sets its start values when the timeline is built (`fromTo`, `from`) shows them at + once. Use `immediateRender: false` when the start should only appear when the tween plays. +- GSAP rounds pixel values, so a fraction of an SVG path length, as used for drawing edges and + beams, is tweened as an attribute: `attr: { 'stroke-dashoffset': 0.2 }`. diff --git a/idea.md b/idea.md index 8646380..928c8e0 100644 --- a/idea.md +++ b/idea.md @@ -1,6 +1,6 @@ # Hexlode product -> Updated: 2026-09-27 (Phase 1 interface redesign: animated home page, top bar, tool pages) +> Updated: 2026-09-28 (directed Studio scenes on the home page, footer credits and wordmark glow) > Delivery plan: [implementation.md](./implementation.md). Vocabulary: [CONTEXT.md](./CONTEXT.md). > Decisions and their reasons: [docs/adr/](./docs/adr/). @@ -45,13 +45,21 @@ it says the work can run on the device without uploading, never that nothing is that there are no accounts. Animations run only while on screen, start from a still first frame rendered on the server, and stop when the system asks for reduced motion. +The Studio section's pictures are short directed scenes that follow one batch of 240 photos: the +graph builds and the batch runs through it, a pointer changes a crop and the preview reframes, four +workers share the last images of the batch, an edited setting reruns only the changed steps, and +the pipeline is saved as a tool. One thing moves at a time, each scene rests on its last frame +before it plays again, and scenes side by side start one after another. With reduced motion each +scene shows one still frame. + Every page shares one frame that stays mounted while pages change. The top bar holds the name, a Tools menu that opens on click and lists the quick tools with a short line each, the Studio, a GitHub link and the colour mode. The bar is opaque. On pages that scroll it lines up with the 1200-pixel column and folds into a floating dock once the page scrolls; on the Studio it spans the window, and moving between the two animates its width. On phones its links move into a menu -button. The footer holds a line about Hexlode, the links to the tools, the Studio, the privacy -page, the codec licences and the repository, and a large dotted wordmark. +button. The footer holds a line about Hexlode, a credit to Damn Labs and Pixelact Studio, the links to +the tools, the Studio, the privacy page, the codec licences, the repository and the other Damn Labs +sites, and a large dotted wordmark whose dots brighten in a circle under the pointer. The colour mode is dark, light or the system's. Dark is the default and is pitch dark. The choice is kept in browser storage and applied before the page paints, so a light page never flashes dark. diff --git a/implementation.md b/implementation.md index d26fc64..b7e419e 100644 --- a/implementation.md +++ b/implementation.md @@ -46,8 +46,9 @@ Active phase: **Phase 1**. ### Application 13. Home page and the six quick tools: Convert, Compress, Resize, Crop, Rotate, Strip metadata. - The home page animates with Motion (`motion/react`) and shows a Studio screenshot taken from a - real run. The site frame lives in the root route so the top bar animates between pages. The + The home page shows a Studio screenshot taken from a real run. Its Studio scenes are GSAP + timelines started by ScrollTrigger (`useScene` in `src/features/home/scene.ts`); interface + motion elsewhere uses Motion (`motion/react`). See ADR 0009. The site frame lives in the root route so the top bar animates between pages. The Hexlode theme in `src/features/theme/`, with dark, light and system colour modes and a self-hosted Figtree font. 14. Studio: node library with every category, drag, search, category filter and a folded rail, diff --git a/package.json b/package.json index 33f7bc2..ac301d4 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dependencies": { "@astryxdesign/core": "^0.2.0", "@fontsource-variable/figtree": "^5.3.0", + "@gsap/react": "^2.1.2", "@jsquash/avif": "^2.1.1", "@jsquash/jpeg": "^1.6.0", "@jsquash/jxl": "^1.3.0", @@ -66,6 +67,7 @@ "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", + "gsap": "^3.15.0", "lucide-react": "^1.28.0", "motion": "^13.4.4", "nitro": "3.0.260610-beta", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5082c9e..69555cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fontsource-variable/figtree': specifier: ^5.3.0 version: 5.3.0 + '@gsap/react': + specifier: ^2.1.2 + version: 2.1.2(gsap@3.15.0)(react@19.2.8) '@jsquash/avif': specifier: ^2.1.1 version: 2.1.1 @@ -95,6 +98,9 @@ importers: drizzle-orm: specifier: ^0.45.1 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(kysely@0.29.4)(pg@8.22.0) + gsap: + specifier: ^3.15.0 + version: 3.15.0 lucide-react: specifier: ^1.28.0 version: 1.28.0(react@19.2.8) @@ -1133,6 +1139,12 @@ packages: '@formatjs/icu-skeleton-parser@2.1.11': resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + '@gsap/react@2.1.2': + resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==} + peerDependencies: + gsap: ^3.12.5 + react: '>=17' + '@internationalized/number@3.6.8': resolution: {integrity: sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==} @@ -2948,6 +2960,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + h3@2.0.1-rc.20: resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} @@ -4948,6 +4963,11 @@ snapshots: '@formatjs/icu-skeleton-parser@2.1.11': {} + '@gsap/react@2.1.2(gsap@3.15.0)(react@19.2.8)': + dependencies: + gsap: 3.15.0 + react: 19.2.8 + '@internationalized/number@3.6.8': dependencies: '@swc/helpers': 0.5.23 @@ -6699,6 +6719,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.15.0: {} + h3@2.0.1-rc.20(crossws@0.4.10(srvx@0.11.22)): dependencies: rou3: 0.8.1 diff --git a/src/features/app-shell/constants.ts b/src/features/app-shell/constants.ts index 51e73a8..16b5f84 100644 --- a/src/features/app-shell/constants.ts +++ b/src/features/app-shell/constants.ts @@ -1,5 +1,13 @@ export const REPOSITORY_URL = 'https://github.com/pixelactstudio/hexlode' +/** Damn Labs, Pixelact Studio's lab for experimental software, which makes Hexlode. */ +export const DAMN_LABS_URL = 'https://damnlabs.com' + +export const PIXELACT_STUDIO_URL = 'https://pixelactstudio.com' + +/** EnvSift, Damn Labs' first product. */ +export const ENVSIFT_URL = 'https://envsift.damnlabs.com' + /** The widest a page's content gets. The top bar lines up with it on every page but the Studio. */ export const PAGE_WIDTH = 1200 diff --git a/src/features/app-shell/footer-wordmark.tsx b/src/features/app-shell/footer-wordmark.tsx new file mode 100644 index 0000000..013c6ed --- /dev/null +++ b/src/features/app-shell/footer-wordmark.tsx @@ -0,0 +1,63 @@ +import { + motion, + useMotionTemplate, + useMotionValue, + useReducedMotion, + useSpring, +} from 'motion/react' +import type { PointerEvent } from 'react' + +/** Radius, in pixels, of the circle of dots that lights up under the pointer. */ +const GLOW_RADIUS = 180 + +const WORDMARK = + 'block select-none bg-[length:5px_5px] bg-clip-text text-center font-bold text-[clamp(88px,19vw,260px)] text-transparent leading-[0.8] tracking-[-0.04em]' + +/** + * The large dotted "Hexlode" at the foot of every page. Under the pointer its dots brighten in a + * circle that fades out towards the edge and trails the pointer a little, then dims when the + * pointer leaves. Only the dots light up; the gaps between them stay dark. + */ +export function FooterWordmark() { + const reduced = useReducedMotion() + const spring = reduced ? { duration: 0 } : { stiffness: 260, damping: 30, mass: 0.6 } + const x = useSpring(useMotionValue(0), spring) + const y = useSpring(useMotionValue(0), spring) + const strength = useSpring(0, reduced ? { duration: 0 } : { stiffness: 120, damping: 24 }) + const mask = useMotionTemplate`radial-gradient(circle ${GLOW_RADIUS}px at ${x}px ${y}px, black, rgb(0 0 0 / 0.35) 45%, transparent 100%), linear-gradient(to bottom, black 40%, transparent)` + + function move(event: PointerEvent) { + const box = event.currentTarget.getBoundingClientRect() + const left = event.clientX - box.left + const top = event.clientY - box.top + // Enter at the pointer rather than sliding in from the last spot the glow was. + if (strength.get() < 0.01) { + x.jump(left) + y.jump(top) + } + x.set(left) + y.set(top) + strength.set(1) + } + + return ( + + ) +} diff --git a/src/features/app-shell/icon-tile.tsx b/src/features/app-shell/icon-tile.tsx index 90e4bba..e35f777 100644 --- a/src/features/app-shell/icon-tile.tsx +++ b/src/features/app-shell/icon-tile.tsx @@ -13,7 +13,7 @@ const TONES: Record = { gray: 'bg-gray-subtle text-gray-vivid', } -const SIZES = { sm: 'size-7 rounded-md', md: 'size-9 rounded-lg', lg: 'size-12 rounded-xl' } +const SIZES = { sm: 'size-7 rounded-sm', md: 'size-9 rounded-md', lg: 'size-12 rounded-lg' } /** An icon on a tinted square, used to tell tools and node categories apart at a glance. */ export function IconTile({ diff --git a/src/features/app-shell/site-footer.tsx b/src/features/app-shell/site-footer.tsx index efae441..254ae54 100644 --- a/src/features/app-shell/site-footer.tsx +++ b/src/features/app-shell/site-footer.tsx @@ -2,7 +2,14 @@ import { Center } from '@astryxdesign/core/Center' import { HStack, VStack } from '@astryxdesign/core/Stack' import { Text } from '@astryxdesign/core/Text' -import { PAGE_WIDTH, REPOSITORY_URL } from '#/features/app-shell/constants' +import { + DAMN_LABS_URL, + ENVSIFT_URL, + PAGE_WIDTH, + PIXELACT_STUDIO_URL, + REPOSITORY_URL, +} from '#/features/app-shell/constants' +import { FooterWordmark } from '#/features/app-shell/footer-wordmark' import { HexlodeMark } from '#/features/app-shell/hexlode-mark' import { QUICK_TOOL_GROUPS } from '#/features/quick-tools/tool-ui' import { QUICK_TOOL_DEFINITIONS } from '#/features/quick-tools/tools' @@ -30,6 +37,14 @@ const COLUMNS: { title: string; links: { label: string; href: string }[] }[] = [ { label: 'Codec licences', href: '/licenses/jsquash.txt' }, ], }, + { + title: 'Damn Labs', + links: [ + { label: 'Damn Labs', href: DAMN_LABS_URL }, + { label: 'EnvSift', href: ENVSIFT_URL }, + { label: 'Pixelact Studio', href: PIXELACT_STUDIO_URL }, + ], + }, ] function FooterLink({ href, label }: { href: string; label: string }) { @@ -46,27 +61,52 @@ function FooterLink({ href, label }: { href: string; label: string }) { ) } -/** The name and a line about Hexlode, the site's links in columns, and a large dotted wordmark. */ +/** A link inside the credit line, underlined so it reads as one inside the sentence. */ +function CreditLink({ href, label }: { href: string; label: string }) { + return ( + + {label} + + ) +} + +/** + * The name and a line about Hexlode, who makes it, the site's links in columns, and a large dotted + * wordmark that lights up under the pointer. + */ export function SiteFooter() { return (
- - - - - - Hexlode - - - - - Image tools and pipelines that run in your browser. Open source under the Apache - License 2.0. - - - + + + + + + + Hexlode + + + + + Image tools and pipelines that run in your browser. Open source under the Apache + License 2.0. + + + + + Built by , a{' '} + product. + + + + {COLUMNS.map((column) => ( @@ -80,12 +120,7 @@ export function SiteFooter() { ))} - +
diff --git a/src/features/home/__tests__/worker-plan.test.ts b/src/features/home/__tests__/worker-plan.test.ts new file mode 100644 index 0000000..fc857b4 --- /dev/null +++ b/src/features/home/__tests__/worker-plan.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { planWorkers } from '#/features/home/worker-plan' + +describe('planWorkers', () => { + it('starts one job on every worker at once', () => { + const jobs = planWorkers([2, 3, 1.5, 2.5, 2], 4) + expect(jobs.slice(0, 4).map((job) => [job.worker, job.start])).toEqual([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]) + }) + + it('gives the next job to the worker that finishes first', () => { + const jobs = planWorkers([2, 3, 1.5, 2.5, 2], 4) + expect(jobs[4]).toEqual({ index: 4, worker: 2, start: 1.5, end: 3.5 }) + }) + + it('never runs two jobs on one worker at the same time', () => { + const jobs = planWorkers([1.2, 1.8, 1.5, 2.1, 1.4, 1.9, 1.6, 1.3, 2, 1.7, 1.5, 1.8], 4) + for (let worker = 0; worker < 4; worker++) { + const own = jobs.filter((job) => job.worker === worker) + for (let index = 1; index < own.length; index++) { + expect(own[index].start).toBe(own[index - 1].end) + } + } + expect(jobs).toHaveLength(12) + }) +}) diff --git a/src/features/home/constants.ts b/src/features/home/constants.ts new file mode 100644 index 0000000..eb8e050 --- /dev/null +++ b/src/features/home/constants.ts @@ -0,0 +1,39 @@ +/* + * The home page's motion primitives. Every scene is built from these timings and easings, so the + * page moves at one pace. Times are in seconds; easings are GSAP names. + */ + +export const BEAT = { + /** A press, a lamp switching on, a badge swapping. */ + quick: 0.2, + /** Something entering or leaving. */ + base: 0.45, + /** A pointer travelling or a picture changing shape. */ + move: 0.7, + /** Long enough to read a changed label. */ + read: 1.4, + /** The pause on a scene's last frame before it starts again. */ + rest: 2.2, +} as const + +export const EASE = { + enter: 'power3.out', + exit: 'power2.in', + move: 'power2.inOut', + steady: 'none', +} as const + +/** Between items that enter one after another. */ +export const STAGGER = 0.08 + +/** + * How much later each column of a grid starts its scene, so cells that come into view together + * play one after another instead of all at once. + */ +export const COLUMN_DELAY = 0.5 + +/** A scene starts when its top passes this point of the viewport, in ScrollTrigger terms. */ +export const SCENE_START = 'top 80%' + +/** The batch every Studio scene follows, from the graph to the saved tool. */ +export const BATCH_SIZE = 240 diff --git a/src/features/home/device-section.tsx b/src/features/home/device-section.tsx index 7cc1a18..d05771f 100644 --- a/src/features/home/device-section.tsx +++ b/src/features/home/device-section.tsx @@ -73,11 +73,7 @@ function InputChip({ }) { return (
- + {input.name} {input.size} @@ -97,7 +93,7 @@ function OutputChip({ }) { return (
- + @@ -117,7 +113,7 @@ function BrowserTab({ }) { return (
@@ -128,7 +124,7 @@ function BrowserTab({ {['Decode', 'Edit', 'Encode'].map((stage) => ( {stage} @@ -311,7 +307,7 @@ export function ClosingCall() { className="pointer-events-none absolute -bottom-64 left-1/2 h-[480px] w-[min(900px,100%)] -translate-x-1/2 rounded-full bg-linear-to-r from-orange-ring/25 via-red-ring/30 to-pink-ring/25 blur-[120px]" /> - + diff --git a/src/features/home/hero.tsx b/src/features/home/hero.tsx index c663888..de5dec0 100644 --- a/src/features/home/hero.tsx +++ b/src/features/home/hero.tsx @@ -29,17 +29,17 @@ function ProductShot() { aria-hidden="true" className="pointer-events-none absolute inset-x-[12%] top-[8%] bottom-[10%] rounded-full bg-red-ring/15 blur-[110px]" /> -
+
- + Hexlode · Studio
-
+
{SHOTS.map((shot) => ( ) } + +/** + * The pointer that acts out a scene: `clickOn` in scene.ts moves it and presses. It starts hidden + * at the top left of its positioned parent, with its tip on that corner. + */ +export function SceneCursor() { + return ( + + ) +} + +/** A label that slides to its next value. */ +export function Rolling({ value }: { value: string }) { + return ( + + + + {value} + + + + ) +} diff --git a/src/features/home/scene.ts b/src/features/home/scene.ts new file mode 100644 index 0000000..7e7b074 --- /dev/null +++ b/src/features/home/scene.ts @@ -0,0 +1,108 @@ +import { useGSAP } from '@gsap/react' +import { gsap } from 'gsap' +import { ScrollTrigger } from 'gsap/ScrollTrigger' +import { useRef } from 'react' + +import { BEAT, EASE, SCENE_START } from '#/features/home/constants' + +gsap.registerPlugin(useGSAP, ScrollTrigger) + +type Query = (selector: string) => Element[] + +/** Adds a scene's tweens to `timeline`. `q` finds elements inside `root`. */ +export type SceneBuilder = (timeline: gsap.core.Timeline, q: Query, root: HTMLElement) => void + +/** + * A directed scene: one GSAP timeline, built once inside the returned element's scope. + * + * The timeline waits until the element scrolls into view, then plays after `delay`, pauses while + * the element is off screen and carries on when it comes back. A scene that loops repeats its + * whole timeline, or nests a repeating timeline after a part that plays once. + * + * When the user asks for reduced motion the scene jumps to the label `poster`, or to its end, and + * stays there, without its pointer. Callbacks on the way still run, so React state matches the + * frame shown. + */ +export function useScene( + build: SceneBuilder, + { delay = 0, repeat = 0, repeatDelay = BEAT.rest }: SceneOptions = {}, +) { + const ref = useRef(null) + useGSAP( + () => { + const element = ref.current + if (!element) return + const timeline = gsap.timeline({ paused: true, repeat, repeatDelay }) + build(timeline, gsap.utils.selector(element), element) + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + timeline.seek(timeline.labels.poster ?? timeline.duration(), false) + gsap.set(element.querySelectorAll('[data-cursor]'), { autoAlpha: 0 }) + return + } + + let started = false + ScrollTrigger.create({ + trigger: element, + start: SCENE_START, + end: 'bottom top', + onToggle: ({ isActive }) => { + if (!isActive) { + timeline.pause() + } else if (started) { + timeline.resume() + } else { + started = true + gsap.delayedCall(delay, () => timeline.play()) + } + }, + }) + }, + { scope: ref }, + ) + return ref +} + +type SceneOptions = { + /** Seconds to wait after the scene comes into view, to follow a scene beside it. */ + delay?: number + /** How many more times the whole timeline plays; -1 for ever. */ + repeat?: number + /** Seconds between repeats. */ + repeatDelay?: number +} + +/** The centre of `target`, in the coordinates of its positioned ancestor `container`. */ +export function centreOf(target: Element | undefined, container: Element | undefined) { + if (!(target instanceof HTMLElement) || !(container instanceof HTMLElement)) return { x: 0, y: 0 } + let x = target.offsetWidth / 2 + let y = target.offsetHeight / 2 + let node: HTMLElement | null = target + while (node && node !== container) { + x += node.offsetLeft + y += node.offsetTop + node = node.offsetParent as HTMLElement | null + } + return { x, y } +} + +/** + * Adds a pointer gliding to the centre of `target` and pressing it, at `position`. The pointer is + * a `SceneCursor` inside `container`. + */ +export function clickOn( + timeline: gsap.core.Timeline, + cursor: Element[], + target: Element | undefined, + container: Element | undefined, + position?: gsap.Position, +) { + const point = centreOf(target, container) + timeline + .to(cursor, { autoAlpha: 1, duration: BEAT.quick }, position) + .to(cursor, { x: point.x, y: point.y, duration: BEAT.move, ease: EASE.move }, '<') + .to(cursor, { scale: 0.8, duration: 0.1, ease: EASE.exit }) + .to(cursor, { scale: 1, duration: BEAT.quick, ease: EASE.enter }) + if (target) timeline.to(target, { scale: 0.94, duration: 0.1, yoyo: true, repeat: 1 }, '<-0.1') + return timeline +} diff --git a/src/features/home/studio-bento.tsx b/src/features/home/studio-bento.tsx index acb95d6..c8d2936 100644 --- a/src/features/home/studio-bento.tsx +++ b/src/features/home/studio-bento.tsx @@ -1,20 +1,33 @@ import { Button } from '@astryxdesign/core/Button' import { Icon } from '@astryxdesign/core/Icon' -import { ArrowRight, Bookmark, Check, LoaderCircle, Workflow } from 'lucide-react' -import { AnimatePresence, motion, useInView } from 'motion/react' -import { type ReactNode, useRef } from 'react' +import { gsap } from 'gsap' +import { + ArrowRight, + Bookmark, + Check, + CornerDownLeft, + LoaderCircle, + Play, + Workflow, +} from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import { type ReactNode, useState } from 'react' import { IconTile, type Tone } from '#/features/app-shell/icon-tile' -import { Drop, FitDrawing, useLoop } from '#/features/home/motion-kit' +import { BATCH_SIZE, BEAT, COLUMN_DELAY, EASE, STAGGER } from '#/features/home/constants' +import { FitDrawing, Rolling, SceneCursor } from '#/features/home/motion-kit' +import { clickOn, useScene } from '#/features/home/scene' import { Cell, Section, SectionHeader } from '#/features/home/section' +import { planWorkers } from '#/features/home/worker-plan' import { TEMPLATES } from '#/features/pipelines/templates' import { NODE_ICONS } from '#/features/studio/node-ui' import { RouterLink } from '#/lib/router-link' /* - * The Studio's features, each with a small moving picture drawn in HTML with the Studio's own - * node icons and colours. Every claim matches idea.md: previews per node, workers per core, the - * step cache, and saving a pipeline as a tool. + * The Studio's features, each a short directed scene drawn in HTML with the Studio's own node + * icons and colours. Every scene follows the same batch of 240 photos, has one thing moving at a + * time, and rests on its last frame before it plays again. Every claim matches idea.md: previews + * per node, workers per core, the step cache, and saving a pipeline as a tool. */ const TONES: Record = { @@ -31,17 +44,36 @@ function NodeIcon({ type }: { type: string }) { return icon ? : null } +/** A card that holds a scene: a 12px corner, so the 4px corners inside sit 8px in. */ +const CARD = 'relative rounded-lg border border-border bg-card shadow-sm' + // ─── Chain steps ──────────────────────────────────────────────────────────── const NODE_WIDTH = 176 const NODE_HEIGHT = 52 const GRAPH_WIDTH = 800 const GRAPH_HEIGHT = 240 -/** Seconds for one pass of items through the whole graph. */ -const PERIOD = 3.6 +/** Seconds between one stage of the graph lighting up and the next. */ +const STAGE_GAP = 0.75 +const LAST_STAGE = 3 +/** + * The beam is a dash a sixth of an edge long, with a gap longer than any edge. It waits just + * before the start, where its round cap cannot show, and runs until it is just past the end. + */ +const BEAM = 0.16 +const BEAM_START = BEAM + 0.05 +const BEAM_END = -1.05 const GRAPH_NODES = [ - { id: 'files', type: 'files', title: 'Files', detail: '240 images', x: 0, y: 94, stage: 0 }, + { + id: 'files', + type: 'files', + title: 'Files', + detail: `${BATCH_SIZE} images`, + x: 0, + y: 94, + stage: 0, + }, { id: 'resize', type: 'resize', @@ -98,48 +130,42 @@ function edgePath(sourceId: string, targetId: string) { type GraphNode = (typeof GRAPH_NODES)[number] -/** A node drawn like the Studio's, with a dot that lights up as items pass through. */ +/** A node drawn like the Studio's, with a lamp that lights as the batch passes through. */ function GraphNodeCard({ node, - isLit, className = '', style, }: { node: GraphNode - isLit: boolean className?: string style?: React.CSSProperties }) { return (
+ {node.title} {node.detail} - {isLit ? ( - - ) : null} +
) } -/** The graph on wider screens: nodes in columns with beams running along the edges. */ -function PipelineGraph({ isLit }: { isLit: boolean }) { +/** The graph on wider screens: nodes in columns, joined by curves the batch runs along. */ +function PipelineGraph() { return ( ( ))} - {isLit - ? GRAPH_EDGES.map(([source, target]) => ( - - )) - : null} + {GRAPH_EDGES.map(([source, target]) => ( + + ))} {GRAPH_NODES.map((node) => ( @@ -194,19 +215,33 @@ function PipelineGraph({ isLit }: { isLit: boolean }) { ) } +/** A short vertical line between stacked steps on phones, drawn in as the next step arrives. */ +function Link({ to }: { to: number }) { + return ( +