diff --git a/.env.example b/.env.example index c750772..68507da 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,3 @@ -VITE_APP_TITLE=Hexlode - # Dormant until cloud mode (ADR 0006). On Dokploy, the Postgres service's internal connection URL. DATABASE_URL=postgresql://user:password@localhost:5432/hexlode @@ -12,6 +10,8 @@ GOOGLE_CLIENT_SECRET= VITE_POSTHOG_KEY= VITE_POSTHOG_HOST=https://us.i.posthog.com VITE_SENTRY_DSN= -VITE_SENTRY_ORG= -VITE_SENTRY_PROJECT= + +# Build time only, for uploading source maps to Sentry. The token is a secret. +SENTRY_ORG= +SENTRY_PROJECT= SENTRY_AUTH_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05aec29..87404b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,14 @@ name: CI +# Every check runs on the pull request. `main` only receives squash merges of checked pull +# requests, so a push there runs the Docker image workflow instead of repeating these. on: - push: - branches: [main] pull_request: workflow_dispatch: concurrency: group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8feeec3..7a84cae 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,8 +1,7 @@ name: CodeQL +# Pull requests, plus a weekly run on `main` that keeps the baseline pull requests compare with. on: - push: - branches: [main] pull_request: schedule: - cron: '17 4 * * 1' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1cff3c2..b322f36 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,10 @@ name: Docker image -# Publishes ghcr.io//hexlode for Dokploy to pull, then asks Dokploy to redeploy. -# `main` updates the `latest` tag; a `v1.2.3` tag also publishes `1.2.3` and `1.2`. Every image -# also gets a `sha-` tag to roll back to. See DEPLOY.md. +# Publishes ghcr.io//hexlode. Every merge to `main` updates the `main` tag. A release, the +# `v1.2.3` tag Release Please starts this workflow for, publishes `latest`, `1.2.3` and `1.2`, then +# calls the Dokploy deploy webhook, since Dokploy runs `latest`. Every image also gets a +# `sha-` tag to roll back to. With the Sentry secret and variables set, the build uploads +# source maps. See DEPLOY.md. on: push: branches: [main] @@ -32,12 +34,24 @@ jobs: - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # The release name the app reports to Sentry: the version for a release tag, else the commit. + - id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "value=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + fi + - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.IMAGE }} + # `latest` only moves on a release, never on a plain merge to `main`. + flavor: latest=false tags: | - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=main,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix=sha-,format=short @@ -49,7 +63,12 @@ jobs: platforms: linux/amd64 load: true tags: hexlode:scan - build-args: HEXLODE_VERSION=${{ steps.meta.outputs.version }} + build-args: | + HEXLODE_VERSION=${{ steps.version.outputs.value }} + SENTRY_ORG=${{ vars.SENTRY_ORG }} + SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} + secrets: | + SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} cache-from: type=gha cache-to: type=gha,mode=max @@ -97,7 +116,14 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: HEXLODE_VERSION=${{ steps.meta.outputs.version }} + # The same arguments as the scan build, so the build stage comes from its cache and the + # source maps are uploaded once. + build-args: | + HEXLODE_VERSION=${{ steps.version.outputs.value }} + SENTRY_ORG=${{ vars.SENTRY_ORG }} + SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} + secrets: | + SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} cache-from: type=gha cache-to: type=gha,mode=max provenance: mode=max @@ -116,23 +142,19 @@ jobs: deploy: name: deploy to Dokploy needs: image - # Only `main` deploys, and only once the Dokploy secrets are set (see DEPLOY.md). - if: github.ref == 'refs/heads/main' + # Only releases deploy, and only once the webhook secret is set (see DEPLOY.md). + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: production steps: - - name: Redeploy + - name: Call the deploy webhook env: - DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} - DOKPLOY_APPLICATION_ID: ${{ secrets.DOKPLOY_APPLICATION_ID }} + DOKPLOY_WEBHOOK_URL: ${{ secrets.DOKPLOY_WEBHOOK_URL }} run: | - if [ -z "$DOKPLOY_URL" ] || [ -z "$DOKPLOY_API_KEY" ] || [ -z "$DOKPLOY_APPLICATION_ID" ]; then - echo "::notice::Dokploy secrets are not set, so Dokploy was not asked to redeploy." + if [ -z "$DOKPLOY_WEBHOOK_URL" ]; then + echo "::notice::DOKPLOY_WEBHOOK_URL is not set, so Dokploy was not asked to deploy." exit 0 fi - curl -fsS -X POST "${DOKPLOY_URL%/}/api/application.deploy" \ - -H "x-api-key: $DOKPLOY_API_KEY" \ - -H 'Content-Type: application/json' \ - -d "{\"applicationId\": \"$DOKPLOY_APPLICATION_ID\"}" + curl -fsS --retry 3 --retry-all-errors -X POST "$DOKPLOY_WEBHOOK_URL" + echo echo "Dokploy is deploying the new image." diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..68eb0a6 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,39 @@ +name: Release Please + +# Keeps a release pull request open on `main` that bumps the version and writes CHANGELOG.md from +# the Conventional Commits merged since the last release. Merging it tags `vX.Y.Z`, creates the +# GitHub release and publishes the versioned image. +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release: + name: release pull request + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + actions: write + steps: + - id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Tags pushed with the workflow token start no workflows, so start the image build directly. + - name: Publish the versioned image + if: steps.release.outputs.release_created == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag_name }} + run: gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index bf34891..bb68249 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,8 +1,7 @@ name: Security +# Dependency changes in pull requests, plus a daily audit of `main` for newly published advisories. on: - push: - branches: [main] pull_request: paths: - pnpm-lock.yaml diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..3633bdf --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{ ".": "0.0.0" } 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/CONTEXT.md b/CONTEXT.md index a063d34..68d3f59 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -47,8 +47,9 @@ A ready-made pipeline offered when the Studio opens. _Avoid_: preset, example **Draft**: -The pipeline open in the Studio, kept in browser storage so a reload does not lose it. It holds -nodes, settings and the name, never images. +The pipeline open in a Studio tab, kept in that tab's session storage so a reload does not lose +it, while a new tab starts fresh. The last draft with unsaved changes is also kept in local storage +for a day, for a new tab to offer. It holds nodes, settings and the name, never images. _Avoid_: autosave, backup **Pipeline file**: diff --git a/DEPLOY.md b/DEPLOY.md index c715730..2bc82c2 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -10,8 +10,9 @@ needs rebuilding to change a setting ([ADR 0008](./docs/adr/0008-one-image-confi | Tag | Updated | | --- | --- | -| `latest` | On every push to `main`. | -| `1.2.3`, `1.2` | When a `v1.2.3` tag is pushed. | +| `latest` | On every release. Production runs this tag. | +| `1.2.3`, `1.2` | On every release: merging the Release Please pull request tags `v1.2.3`. | +| `main` | On every merge to `main`, released or not. Use it to try unreleased changes. | | `sha-abc1234` | On every build. Use it to pin or roll back to one commit. | The container listens on port `3000`, runs as an unprivileged user, and answers `GET /api/health` @@ -43,18 +44,27 @@ runs with analytics and error reports off. | --- | --- | | `VITE_POSTHOG_KEY` | PostHog project key. Turns on cookieless analytics. | | `VITE_POSTHOG_HOST` | PostHog API host, for example `https://eu.i.posthog.com`. Defaults to the US host. | -| `VITE_SENTRY_DSN` | Sentry DSN. Turns on error reports in the browser and on the server. | +| `VITE_SENTRY_DSN` | Sentry DSN. Turns on error reports, logs and tracing in the browser and on the server. | | `PORT` | The port the server listens on. Defaults to `3000`; change the domain's port to match. | The `VITE_*` values are public: they reach every visitor's browser. Never put a secret in a -variable that starts with `VITE_`. A change takes effect on the next deploy or restart. +variable that starts with `VITE_`. A change takes effect on the next deploy or restart. The image +sets `HEXLODE_VERSION` itself; Sentry and PostHog label reports and events with it. + +The Sentry organisation, project and auth token are not runtime settings. They are only needed +where the image is built, to upload source maps; see [Readable Sentry stack traces](#readable-sentry-stack-traces). Hexlode sends PostHog cookieless events, so in the PostHog project turn on **cookieless server -hash mode** and **Discard client IP data**. Without the first, PostHog accepts the events and then -drops them. The app sends only its own named events, such as `page_viewed`, and no `$pageview`, so -look for them under **Activity → Events**; the Web analytics dashboard stays empty. PostHog's -onboarding snippet `posthog.capture(…)` does not work in the console, because the app does not put -PostHog on `window`. +hash mode** (**Project settings → Web analytics**) and **Discard client IP data**. Without the +first, PostHog answers `200 OK` and then drops the events. PostHog also answers `200 OK` for a +wrong project key, or for a key sent to the other region's host, so check that `VITE_POSTHOG_KEY` +is the project's key and `VITE_POSTHOG_HOST` matches its region (`us` or `eu`). + +PostHog records `$pageview`, `$pageleave`, clicks, heatmaps and web vitals by itself, so the Web +analytics dashboard fills in. The app's own events, such as `run_started` and `node_added`, are +under **Activity → Events**. PostHog's onboarding snippet `posthog.capture(…)` does not work in +the console, because the app does not put PostHog on `window`; open a page with +`?__posthog_debug=true` to see what PostHog sends. ### 4. Add the domain @@ -97,24 +107,52 @@ runs Node directly: Then click **Deploy**. Open `https://your-domain/api/health` to see the running version. -## Deploying on every push to `main` +## Deploying on every release -The `Docker image` workflow asks Dokploy to redeploy once the new image is pushed. Give it three -secrets in GitHub (**Settings → Secrets and variables → Actions**, as repository secrets or in the -`production` environment): +Merging a pull request into `main` publishes the `main` image but deploys nothing. Merging the +Release Please pull request makes a release: the `Docker image` workflow publishes `latest` and the +version tags, then calls the application's Dokploy deploy webhook, and Dokploy pulls `latest` and +redeploys. Copy the webhook from the application's +**Deployments** tab in Dokploy and save it in GitHub (**Settings → Secrets and variables → +Actions**) as the repository secret `DOKPLOY_WEBHOOK_URL`. Until it is set, the workflow publishes +the image and skips the deploy. -| Secret | Value | -| --- | --- | -| `DOKPLOY_URL` | Your Dokploy address, for example `https://dokploy.example.com`. | -| `DOKPLOY_API_KEY` | A token from Dokploy's `/settings/profile` page, **API/CLI** section. | -| `DOKPLOY_APPLICATION_ID` | The application's ID: the last part of its address in Dokploy. | +## Readable Sentry stack traces + +The build uploads source maps to Sentry, then removes them from the image, when it has these +values. Add them in GitHub under **Settings → Secrets and variables → Actions**: + +| Name | Kind | Value | +| --- | --- | --- | +| `SENTRY_AUTH_TOKEN` | Secret | An organisation token from Sentry: **Settings → Developer Settings → Organization Tokens**. | +| `SENTRY_ORG` | Variable | The organisation slug, from the Sentry address: `https://.sentry.io`. | +| `SENTRY_PROJECT` | Variable | The project slug, from **Settings → Projects**. | + +Without them the image builds the same and Sentry shows minified stack traces. + +## Monitoring with Sentry + +With `VITE_SENTRY_DSN` set, Sentry receives: + +- **Errors** from the browser, server requests and server functions, with file names removed. +- **Logs**: warnings and errors the app writes to the console, with file names removed. +- **Traces** of a fifth of page loads, navigations and server requests, under **Explore → Traces** + and **Insights**. + +Browser reports go to a same-origin route the build generates, which forwards them to Sentry, so +content blockers do not drop them. Session replay stays off. + +Set these up in Sentry itself: -Until they are set, the workflow publishes the image and skips the deploy. +- **Uptime monitor** (**Insights → Uptime**): check `https://your-domain/api/health` so Sentry + alerts you when the site is down. +- **Alerts** (**Alerts → Create alert**): for example, email on every new issue, or when errors in + an hour pass a number. ## Rolling back Change the image on the **General** tab to an earlier `sha-…` tag and deploy. Every published tag -is listed on the package's GitHub page. Switch back to `latest` to follow `main` again. +is listed on the package's GitHub page. Switch back to `latest` to follow releases again. ## Building on the server instead @@ -140,5 +178,6 @@ the same image and the same kind of settings: ## Running the image anywhere ```bash -docker run -p 3000:3000 -e VITE_POSTHOG_KEY=phc_… ghcr.io/pixelactstudio/hexlode:latest +docker run -p 3000:3000 -e VITE_POSTHOG_KEY=phc_… -e VITE_SENTRY_DSN=https://… \ + ghcr.io/pixelactstudio/hexlode:latest ``` diff --git a/Dockerfile b/Dockerfile index 141c902..5b719fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,13 @@ RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile COPY . . -RUN pnpm build && chmod -R a+rX .output +# With a Sentry auth token (a build secret), the build uploads source maps and removes them from the +# output. Without one it skips the upload. +ARG HEXLODE_VERSION=dev +ARG SENTRY_ORG +ARG SENTRY_PROJECT +RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN,env=SENTRY_AUTH_TOKEN \ + SENTRY_RELEASE="$HEXLODE_VERSION" pnpm build && chmod -R a+rX .output # Nitro leaves Sentry out of the server bundle, so install it on its own at the locked version. RUN SENTRY=$(node -p "require('@sentry/tanstackstart-react/package.json').version") \ && npm install --prefix /runtime --omit=dev --omit=optional --ignore-scripts \ diff --git a/README.md b/README.md index 88e2b36..8081278 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,33 @@ -# 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. + +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. +- Make the Studio canvas show real work: progress, results and errors. +- Never send image bytes, filenames, thumbnails or metadata to analytics. ## Run locally @@ -38,7 +61,8 @@ Browser tests use the Chromium at `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`, or the ## Docker -CI publishes `ghcr.io/pixelactstudio/hexlode` for x86 and ARM on every push to `main`. +CI publishes `ghcr.io/pixelactstudio/hexlode` for x86 and ARM: `main` on every merge to `main`, +and `latest` with the version tags on every release. ```bash docker run -p 3000:3000 ghcr.io/pixelactstudio/hexlode:latest @@ -48,12 +72,9 @@ Settings come from the container's environment, for example `-e VITE_POSTHOG_KEY `-e VITE_SENTRY_DSN=…` to enable analytics and error reports. [DEPLOY.md](./DEPLOY.md) covers Dokploy, every variable, zero-downtime updates and deploying on each push. -## Documents - -[idea.md](./idea.md) describes the product, [implementation.md](./implementation.md) the plan and -engine, [CONTEXT.md](./CONTEXT.md) the vocabulary and [docs/adr/](./docs/adr/) the decisions. - ## License [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). diff --git a/docs/adr/0005-cookieless-explicit-analytics.md b/docs/adr/0005-cookieless-explicit-analytics.md index a4e05ee..7d9dfc2 100644 --- a/docs/adr/0005-cookieless-explicit-analytics.md +++ b/docs/adr/0005-cookieless-explicit-analytics.md @@ -1,9 +1,25 @@ -# Cookieless analytics with explicit events - -PostHog runs with `cookieless_mode: 'always'` and `person_profiles: 'never'`, so it stores nothing -in the browser and the app needs no consent banner. PostHog hashes each visitor's IP address, user -agent and host into an anonymous ID that changes daily, and the project discards the IP afterwards. -The client leaves `$ip` alone: PostHog drops cookieless events that arrive without one. Session -replay and autocapture are off because they would record file names shown on screen. The app sends its own detailed events -from one analytics module instead. Events never contain file names, paths, pixels, image metadata -or text the user types. +# Cookieless analytics with masked autocapture + +PostHog starts as its TanStack Start guide shows (`defaults`, `api_host`), with +`cookieless_mode: 'always'` and `person_profiles: 'never'`, so it stores nothing in the browser and +the app needs no consent banner. PostHog hashes each visitor's IP address, user agent and host into +an anonymous ID that changes daily, and the project discards the IP afterwards. The client leaves +`$ip` alone: PostHog drops cookieless events that arrive without one. + +PostHog captures pageviews, page leaves, clicks, rage and dead clicks, heatmaps and web vitals by +itself, which fills the web analytics dashboard. File names are shown on screen, so autocapture +masks every element's text and attributes (`mask_all_text`, `mask_all_element_attributes`), and +session replay stays off. PostHog's exception capture is off too: Sentry reports errors after +removing file names. + +The app also sends its own named events from one analytics module, each checked against a schema. +They carry the product signal autocapture cannot: tools, pipeline shapes, node types, settings, +counts, timings and error codes. Events never contain file names, paths, pixels, image metadata or +text the user types. + +## Considered options + +- **Explicit events only** (the first version). Private, but the web analytics dashboard stayed + empty and nothing showed where people clicked or got stuck. +- **Autocapture with text.** Buttons would be named in PostHog, but clicks on file lists would + send file names. 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..251a229 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. @@ -111,8 +119,13 @@ quick tools. - A new Studio opens a template picker: Web-ready photos, Photos for email, Remove location, Square thumbnails, WebP and AVIF, Responsive image set, Watermark and compress, Instagram carousel, and Blank. The picker also imports a `.hexlode` file and opens saved pipelines. -- The Studio keeps the open pipeline as a draft in browser storage, so a reload does not lose it. - Images are not kept; the user adds them again. +- Each tab keeps its open pipeline as a draft, so a reload does not lose it, while a new tab or + visit to `/studio` starts at the template picker. A saved pipeline opens at + `/studio?pipeline=`. For a day, the picker offers to continue the last pipeline with unsaved + changes from another tab, in case a tab was closed by mistake. Images are not kept; the user adds + them again. +- The toolbar has icon buttons for a new pipeline, which asks first when there are unsaved changes, + and for saving, with a dot while changes are unsaved. - Run stays disabled until the pipeline has images and an Output node, and the canvas offers to add the Output node. diff --git a/implementation.md b/implementation.md index d26fc64..940e2a6 100644 --- a/implementation.md +++ b/implementation.md @@ -46,13 +46,14 @@ 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, inspector, template picker, live previews, run statistics on nodes and connections, undo and - redo, right-click menus, a draft that survives a reload, narrow-screen message. + redo, right-click menus, a per-tab draft that survives a reload, narrow-screen message. 15. Save in browser storage, `.hexlode` export and import, pipeline tools. ### Node batch 1 @@ -146,20 +147,32 @@ removed. - The batch of 500 images of 12 megapixels runs with `pnpm test:scale`. It takes minutes, so it is outside `pnpm validate`; run it before closing a phase. - `pnpm validate` passes before every commit. -- CI (`.github/workflows/`) runs on every push to `main` and on every pull request: - Biome, types, commit messages, the generated theme and route tree, actionlint and hadolint; - unit tests on Linux, macOS and Windows; browser tests in three engines; coverage; the build with - a smoke test of the server; and the Docker image with a smoke test of the container, its health - check, runtime settings and an ARM build. CodeQL, `pnpm audit`, dependency review and PR titles - run in their own workflows, and the scale test runs weekly or on demand. +- CI (`.github/workflows/`) runs on every pull request: Biome, types, commit messages, the + generated theme and route tree, actionlint and hadolint; unit tests on Linux, macOS and Windows; + browser tests in three engines; coverage; the build with a smoke test of the server; and the + Docker image with a smoke test of the container, its health check, runtime settings and an ARM + build. CodeQL, `pnpm audit`, dependency review and PR titles run in their own workflows. A push + to `main` only publishes the `main` image, since the pull request already ran the checks; + CodeQL, the audit and the scale test also run on a schedule. +- Release Please keeps a release pull request open on `main` from the Conventional Commits merged + there; merging it tags the version, writes the changelog, publishes `latest` and the version tags, and + deploys. ## Analytics -- PostHog uses `cookieless_mode: 'always'` and `person_profiles: 'never'`, with session replay and - autocapture turned off. The app sends its own events from one analytics module - ([ADR 0005](./docs/adr/0005-cookieless-explicit-analytics.md)). -- Sentry sends errors with `sendDefaultPii: false` and no replay. File names are removed from error - messages before sending. +- PostHog starts as its TanStack Start guide shows, with `cookieless_mode: 'always'` and + `person_profiles: 'never'`. It captures pageviews, page leaves, clicks, heatmaps and web vitals + with element text and attributes masked; session replay is off. The app also sends its own events + from one analytics module ([ADR 0005](./docs/adr/0005-cookieless-explicit-analytics.md)). +- Sentry starts as its TanStack Start guide shows: `src/client.tsx` in the browser, + `instrument.server.mjs` on the server, `src/server.ts` and the global middlewares in + `src/start.ts`. It sends errors, logs and a fifth of traces, with `sendDefaultPii: false` and no + replay, through a same-origin tunnel route. File names are removed from messages, exceptions, + breadcrumbs and logs before sending. Source maps upload at build time when `SENTRY_AUTH_TOKEN`, + `SENTRY_ORG` and `SENTRY_PROJECT` are set. +- The browser reads the public settings from a `hexlode-config` meta tag the root route writes, so + both start before hydration. Their libraries load on their own, so a content blocker cannot stop + the app. - The PostHog project must have cookieless mode enabled and "Discard client IP data" turned on; without the first, PostHog ignores cookieless events. The client must not clear `$ip`: PostHog hashes it into the daily anonymous ID and drops cookieless events without it. @@ -168,8 +181,8 @@ removed. ## Deployment The app runs as one Docker container that serves the Nitro build. The `Docker image` workflow -publishes `ghcr.io/pixelactstudio/hexlode` for x86 and ARM on every push to `main`, then asks -Dokploy on the maintainer's VPS to redeploy. Dokploy pulls the image, sets its environment and +publishes `ghcr.io/pixelactstudio/hexlode` for x86 and ARM: `main` on every merge to `main`, and +`latest` on every release, after which it calls the Dokploy deploy webhook on the maintainer's VPS. Dokploy pulls the image, sets its environment and handles the domain and HTTPS ([ADR 0008](./docs/adr/0008-one-image-configured-at-runtime.md)). `/api/health` answers the container health check that Dokploy's zero-downtime updates wait for. A future cloud mode adds a Postgres service on the same VPS, reached through `DATABASE_URL`. diff --git a/instrument.server.mjs b/instrument.server.mjs index c6bf4b5..d18cf1e 100644 --- a/instrument.server.mjs +++ b/instrument.server.mjs @@ -5,11 +5,15 @@ const sentryDsn = import.meta.env?.VITE_SENTRY_DSN ?? process.env.VITE_SENTRY_DS if (sentryDsn) { Sentry.init({ dsn: sentryDsn, + release: process.env.HEXLODE_VERSION, + environment: process.env.NODE_ENV === 'production' ? 'production' : 'development', sendDefaultPii: false, dataCollection: { userInfo: false, httpBodies: [], }, - tracesSampleRate: 0, + enableLogs: true, + // Matches TRACES_SAMPLE_RATE in src/features/usage/constants.ts. + tracesSampleRate: 0.2, }) } diff --git a/package.json b/package.json index 33f7bc2..3cde7d9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "hexlode", + "version": "0.0.0", "private": true, "type": "module", "packageManager": "pnpm@11.3.0", @@ -39,6 +40,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 +68,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/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c2567ad --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "ebb070782763dfdb34fdc99240bfdabf034f97ad", + "packages": { + ".": { + "release-type": "node", + "package-name": "hexlode", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/src/client.tsx b/src/client.tsx new file mode 100644 index 0000000..8de511d --- /dev/null +++ b/src/client.tsx @@ -0,0 +1,25 @@ +import { StartClient } from '@tanstack/react-start/client' +import { StrictMode, startTransition } from 'react' +import { hydrateRoot } from 'react-dom/client' + +import { PUBLIC_CONFIG_META } from '#/features/usage/constants' +import { startErrorReporting } from '#/features/usage/error-reports' +import { startAnalytics } from '#/features/usage/usage' +import { parsePublicConfigJson } from '#/features/usage/validators' + +// Sentry and PostHog start before hydration, as their guides ask, from the settings the server +// wrote into the page. They load on their own, so a content blocker cannot stop the app. +const config = parsePublicConfigJson( + document.querySelector(`meta[name="${PUBLIC_CONFIG_META}"]`)?.getAttribute('content'), +) +void startErrorReporting(config) +void startAnalytics(config) + +startTransition(() => { + hydrateRoot( + document, + + + , + ) +}) diff --git a/src/env.ts b/src/env.ts index e1120c5..6753c83 100644 --- a/src/env.ts +++ b/src/env.ts @@ -10,16 +10,17 @@ export const env = createEnv({ BETTER_AUTH_URL: z.url().optional(), GOOGLE_CLIENT_ID: z.string().min(1).optional(), GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), + // Build time only: the Sentry Vite plugin uploads source maps with them. SENTRY_AUTH_TOKEN: z.string().min(1).optional(), + SENTRY_ORG: z.string().min(1).optional(), + SENTRY_PROJECT: z.string().min(1).optional(), + HEXLODE_VERSION: z.string().min(1).optional(), }, clientPrefix: 'VITE_', client: { - VITE_APP_TITLE: z.string().min(1).optional(), VITE_POSTHOG_KEY: z.string().min(1).optional(), VITE_POSTHOG_HOST: z.url().optional(), VITE_SENTRY_DSN: z.url().optional(), - VITE_SENTRY_ORG: z.string().min(1).optional(), - VITE_SENTRY_PROJECT: z.string().min(1).optional(), }, runtimeEnv: isServer ? process.env : import.meta.env, isServer, 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 ( +