+
+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 (
+ strength.set(0)}
+ >
+
+ Hexlode
+
+
+ Hexlode
+
+
+ )
+}
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 (
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 (
{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 (
)
}
-/** 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 (
{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 (
+
+ )
+}
+
/** The same graph on phones, stacked from top to bottom. */
-function PipelineStack({ isLit }: { isLit: boolean }) {
+function PipelineStack() {
const [files, resize, webp, avif, web, thumbs] = GRAPH_NODES
- const card = (node: GraphNode) => (
-
- )
+ const card = (node: GraphNode) =>
return (
)
}
diff --git a/src/features/home/tool-demos.tsx b/src/features/home/tool-demos.tsx
index 54b7765..69b3ca5 100644
--- a/src/features/home/tool-demos.tsx
+++ b/src/features/home/tool-demos.tsx
@@ -3,7 +3,7 @@ import { Aperture, CalendarClock, Camera, Check, MapPin, RotateCw } from 'lucide
import { AnimatePresence, motion, useSpring, useTransform } from 'motion/react'
import { useEffect } from 'react'
-import { useLoop } from '#/features/home/motion-kit'
+import { Rolling, useLoop } from '#/features/home/motion-kit'
import type { QuickTool } from '#/features/quick-tools/tools'
/*
@@ -24,26 +24,6 @@ function Photo({ name = 'dusk' }: { name?: 'dusk' | 'dawn' | 'desert' }) {
)
}
-/** A label that slides to its next value. */
-function Rolling({ value }: { value: string }) {
- return (
-
-
-
- {value}
-
-
-
- )
-}
-
function FileTile({
format,
size,
@@ -55,13 +35,13 @@ function FileTile({
}) {
return (
-
+
@@ -123,7 +103,7 @@ function CompressDemo() {
return (
@@ -174,7 +154,7 @@ function ResizeDemo() {
-
+
@@ -231,7 +211,7 @@ function RotateDemo() {
>
-
+
@@ -254,10 +234,10 @@ function StripDemo() {
return (
-
+
@@ -276,7 +256,7 @@ function StripDemo() {
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 24, transition: { duration: 0.3 } }}
- className="flex items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-xs"
+ className="flex items-center gap-2 rounded bg-muted px-2 py-1.5 text-xs"
>
{field.label}
diff --git a/src/features/home/worker-plan.ts b/src/features/home/worker-plan.ts
new file mode 100644
index 0000000..7ea9cad
--- /dev/null
+++ b/src/features/home/worker-plan.ts
@@ -0,0 +1,16 @@
+export type PlannedJob = { index: number; worker: number; start: number; end: number }
+
+/**
+ * Hands out jobs the way the engine's worker pool does: every worker starts one at once, and each
+ * next job goes to the worker that frees up first, the lowest-numbered one on a tie. Times are in
+ * the same unit as `durations`.
+ */
+export function planWorkers(durations: number[], workers: number): PlannedJob[] {
+ const freeAt = Array.from({ length: workers }, () => 0)
+ return durations.map((duration, index) => {
+ const worker = freeAt.indexOf(Math.min(...freeAt))
+ const start = freeAt[worker]
+ freeAt[worker] = start + duration
+ return { index, worker, start, end: start + duration }
+ })
+}
From 8d312a1d8493296e6f2a3ee0d571f9cfc8403c54 Mon Sep 17 00:00:00 2001
From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com>
Date: Mon, 28 Sep 2026 17:54:48 +0530
Subject: [PATCH 3/5] ci: automate releases and expand Sentry observability
- Add Release Please versioning and image publishing
- Upload Sentry source maps and enable logs and tracing
- Improve privacy-safe PostHog autocapture and deployment docs
---
.env.example | 8 +--
.github/workflows/ci.yml | 6 +-
.github/workflows/codeql.yml | 3 +-
.github/workflows/docker.yml | 50 ++++++++-----
.github/workflows/release-please.yml | 39 ++++++++++
.github/workflows/security.yml | 3 +-
.release-please-manifest.json | 1 +
DEPLOY.md | 72 ++++++++++++++-----
Dockerfile | 8 ++-
README.md | 5 --
.../adr/0005-cookieless-explicit-analytics.md | 34 ++++++---
implementation.md | 37 ++++++----
instrument.server.mjs | 6 +-
package.json | 1 +
release-please-config.json | 13 ++++
src/client.tsx | 25 +++++++
src/env.ts | 7 +-
src/features/runs/engine-unavailable.tsx | 7 +-
src/features/studio/node-inspector.tsx | 6 +-
src/features/studio/studio-canvas.tsx | 10 ++-
src/features/theme/colour-mode-menu.tsx | 7 +-
.../usage/__tests__/public-config.test.ts | 17 ++++-
src/features/usage/__tests__/start.test.ts | 53 ++++++++++++--
src/features/usage/__tests__/usage.test.ts | 65 +++++++++++++++--
src/features/usage/constants.ts | 13 ++++
src/features/usage/error-reports.ts | 43 ++++++++---
src/features/usage/events.ts | 15 ++++
src/features/usage/public-config.ts | 1 +
src/features/usage/scrub.ts | 20 ++++++
src/features/usage/types.ts | 2 +
src/features/usage/usage.ts | 37 +++++-----
src/features/usage/validators.ts | 30 ++++++--
src/routeTree.gen.ts | 3 +-
src/router.tsx | 3 +
src/routes/__root.tsx | 13 ++--
src/routes/privacy.tsx | 13 ++--
src/server.ts | 11 +++
src/start.ts | 11 +++
vite.config.ts | 5 ++
39 files changed, 561 insertions(+), 142 deletions(-)
create mode 100644 .github/workflows/release-please.yml
create mode 100644 .release-please-manifest.json
create mode 100644 release-please-config.json
create mode 100644 src/client.tsx
create mode 100644 src/features/usage/constants.ts
create mode 100644 src/server.ts
create mode 100644 src/start.ts
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..4c1fab0 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -1,8 +1,9 @@
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 for Dokploy to pull, then calls the Dokploy deploy webhook.
+# `main` updates the `latest` tag; a `v1.2.3` tag, which Release Please starts this workflow for,
+# publishes `1.2.3` and `1.2`. 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,6 +33,15 @@ 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:
@@ -49,7 +59,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 +112,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 +138,19 @@ jobs:
deploy:
name: deploy to Dokploy
needs: image
- # Only `main` deploys, and only once the Dokploy secrets are set (see DEPLOY.md).
+ # Only `main` deploys, and only once the webhook secret is set (see DEPLOY.md).
if: github.ref == 'refs/heads/main'
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/DEPLOY.md b/DEPLOY.md
index c715730..7b92604 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -11,7 +11,7 @@ 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. |
+| `1.2.3`, `1.2` | When a release is made: merging the Release Please pull request tags `v1.2.3`. |
| `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 +43,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
@@ -99,17 +108,43 @@ Then click **Deploy**. Open `https://your-domain/api/health` to see the running
## Deploying on every push to `main`
-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):
+The `Docker image` workflow calls the application's Dokploy deploy webhook once the new image is
+pushed, 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
@@ -140,5 +175,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 913922c..7f21088 100644
--- a/README.md
+++ b/README.md
@@ -71,11 +71,6 @@ 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
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/implementation.md b/implementation.md
index b7e419e..7835fdf 100644
--- a/implementation.md
+++ b/implementation.md
@@ -147,20 +147,31 @@ 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 and deploys the 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 and publishes the versioned image.
## 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.
@@ -169,8 +180,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 on every push to `main`, then 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 ac301d4..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",
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/runs/engine-unavailable.tsx b/src/features/runs/engine-unavailable.tsx
index a156ad1..cc10cbb 100644
--- a/src/features/runs/engine-unavailable.tsx
+++ b/src/features/runs/engine-unavailable.tsx
@@ -5,6 +5,7 @@ import { Lock, MonitorX } from 'lucide-react'
import { type ReactNode, useEffect, useState } from 'react'
import { engineProblem } from '#/features/runs/engine-runtime'
+import { track } from '#/features/usage/usage'
const MESSAGES = {
insecure: {
@@ -28,7 +29,11 @@ const MESSAGES = {
*/
export function EngineGate({ children }: { children: ReactNode }) {
const [problem, setProblem] = useState>(null)
- useEffect(() => setProblem(engineProblem()), [])
+ useEffect(() => {
+ const found = engineProblem()
+ setProblem(found)
+ if (found) track('engine_unavailable', { reason: found })
+ }, [])
if (problem === null) return children
const message = MESSAGES[problem]
return (
diff --git a/src/features/studio/node-inspector.tsx b/src/features/studio/node-inspector.tsx
index 8a6596e..a1c43d9 100644
--- a/src/features/studio/node-inspector.tsx
+++ b/src/features/studio/node-inspector.tsx
@@ -24,6 +24,7 @@ import { SourceList } from '#/features/runs/source-list'
import { NODE_TYPES_WITH_SETTINGS, NodeSettings } from '#/features/studio/node-settings'
import { NODE_ICONS, toneOf } from '#/features/studio/node-ui'
import type { PreviewState, PreviewView, StudioSession } from '#/features/studio/studio-session'
+import { track } from '#/features/usage/usage'
import { formatBytes, formatChange, formatCount, formatDuration } from '#/lib/format'
/** A part of the inspector: a small uppercase title, then its content, under a divider. */
@@ -328,7 +329,10 @@ export function NodeInspector({
icon={}
size="sm"
variant="ghost"
- onClick={() => session.store.removeNodes([node.id])}
+ onClick={() => {
+ session.store.removeNodes([node.id])
+ track('node_removed', { nodeType: node.type, method: 'inspector' })
+ }}
isDisabled={run.running}
/>
) : null}
diff --git a/src/features/studio/studio-canvas.tsx b/src/features/studio/studio-canvas.tsx
index 8804af3..da7cef3 100644
--- a/src/features/studio/studio-canvas.tsx
+++ b/src/features/studio/studio-canvas.tsx
@@ -222,7 +222,7 @@ export function StudioCanvas({
}
if (Object.keys(sizes).length > 0) setMeasured((current) => ({ ...current, ...sizes }))
if (Object.keys(positions).length > 0) store.moveNodes(positions)
- if (removed.length > 0) store.removeNodes(removed)
+ if (removed.length > 0) removeNodes(removed, 'keyboard')
}
const onEdgesChange = (changes: EdgeChange[]) => {
@@ -244,6 +244,12 @@ export function StudioCanvas({
const typeOf = (id: string) => studio.pipeline.nodes.find((node) => node.id === id)?.type ?? ''
+ const removeNodes = (ids: string[], method: 'keyboard' | 'menu') => {
+ const types = ids.map(typeOf).filter((type) => type && type !== 'files')
+ store.removeNodes(ids)
+ for (const nodeType of types) track('node_removed', { nodeType, method })
+ }
+
const onConnect = (connection: FlowConnection) => {
const candidate = {
source: connection.source,
@@ -363,7 +369,7 @@ export function StudioCanvas({
label: 'Delete',
icon: Trash2,
isDisabled: running,
- onClick: () => store.removeNodes([node.id]),
+ onClick: () => removeNodes([node.id], 'menu'),
},
]),
]
diff --git a/src/features/theme/colour-mode-menu.tsx b/src/features/theme/colour-mode-menu.tsx
index b3760f4..5936087 100644
--- a/src/features/theme/colour-mode-menu.tsx
+++ b/src/features/theme/colour-mode-menu.tsx
@@ -11,6 +11,7 @@ import { colourModeStore } from '#/features/theme/colour-mode'
import { DEFAULT_COLOUR_MODE } from '#/features/theme/constants'
import type { ColourMode } from '#/features/theme/types'
import { colourModeSchema } from '#/features/theme/validators'
+import { track } from '#/features/usage/usage'
const MODES: { value: ColourMode; label: string; icon: typeof Moon }[] = [
{ value: 'dark', label: 'Dark', icon: Moon },
@@ -45,7 +46,11 @@ export function ColourModeMenu() {
colourModeStore().set(colourModeSchema.parse(value))}
+ onChange={(value) => {
+ const next = colourModeSchema.parse(value)
+ colourModeStore().set(next)
+ track('colour_mode_changed', { mode: next })
+ }}
>
{MODES.map((entry) => (
{
it('reads the PostHog and Sentry settings from the server environment', () => {
@@ -9,12 +9,14 @@ describe('public config', () => {
VITE_POSTHOG_KEY: 'phc_live',
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
VITE_SENTRY_DSN: 'https://key@o1.ingest.sentry.io/2',
+ HEXLODE_VERSION: '1.4.0',
DATABASE_URL: 'postgresql://secret@db/hexlode',
}),
).toEqual({
posthogKey: 'phc_live',
posthogHost: 'https://eu.i.posthog.com',
sentryDsn: 'https://key@o1.ingest.sentry.io/2',
+ appVersion: '1.4.0',
})
})
@@ -27,4 +29,17 @@ describe('public config', () => {
}),
).toEqual({})
})
+
+ // The page carries the config so the browser can start Sentry before it hydrates.
+ it('reads the config back from the page, dropping anything unexpected', () => {
+ const config = {
+ posthogKey: 'phc_live',
+ sentryDsn: 'https://key@o1.ingest.sentry.io/2',
+ appVersion: '1.4.0',
+ }
+ expect(parsePublicConfigJson(JSON.stringify({ ...config, extra: 'x' }))).toEqual(config)
+ expect(parsePublicConfigJson(JSON.stringify({ sentryDsn: 'not a url' }))).toEqual({})
+ expect(parsePublicConfigJson('{broken')).toEqual({})
+ expect(parsePublicConfigJson(null)).toEqual({})
+ })
})
diff --git a/src/features/usage/__tests__/start.test.ts b/src/features/usage/__tests__/start.test.ts
index 60297f1..69ebf8d 100644
--- a/src/features/usage/__tests__/start.test.ts
+++ b/src/features/usage/__tests__/start.test.ts
@@ -21,14 +21,59 @@ describe('starting analytics and error reports', () => {
)
})
- it('starts Sentry with the DSN from the public config', async () => {
+ it('starts Sentry with the DSN and release from the public config, with logs and tracing', async () => {
vi.stubGlobal('window', {})
const init = vi.fn()
- vi.doMock('@sentry/tanstackstart-react', () => ({ init }))
+ vi.doMock('@sentry/tanstackstart-react', () => ({
+ init,
+ consoleLoggingIntegration: () => ({ name: 'ConsoleLogs' }),
+ }))
const { startErrorReporting } = await import('#/features/usage/error-reports')
- await startErrorReporting({ sentryDsn: 'https://key@o1.ingest.sentry.io/2' })
+ await startErrorReporting({
+ sentryDsn: 'https://key@o1.ingest.sentry.io/2',
+ appVersion: '1.4.0',
+ })
expect(init).toHaveBeenCalledWith(
- expect.objectContaining({ dsn: 'https://key@o1.ingest.sentry.io/2' }),
+ expect.objectContaining({
+ dsn: 'https://key@o1.ingest.sentry.io/2',
+ release: '1.4.0',
+ sendDefaultPii: false,
+ enableLogs: true,
+ tracesSampleRate: expect.any(Number),
+ }),
)
+ const options = init.mock.calls[0][0]
+ expect(options.tracesSampleRate).toBeGreaterThan(0)
+ expect(options.replaysSessionSampleRate ?? 0).toBe(0)
+ expect(options.beforeSendLog({ message: 'Lost a.jpg' })).toEqual({ message: 'Lost [file]' })
+ })
+
+ it('traces router navigations once Sentry has started', async () => {
+ vi.stubGlobal('window', {})
+ const addIntegration = vi.fn()
+ const routerIntegration = vi.fn((router: unknown) => ({ name: 'Router', router }))
+ vi.doMock('@sentry/tanstackstart-react', () => ({
+ init: vi.fn(),
+ addIntegration,
+ consoleLoggingIntegration: () => ({ name: 'ConsoleLogs' }),
+ tanstackRouterBrowserTracingIntegration: routerIntegration,
+ }))
+ const { startErrorReporting, traceRouter } = await import('#/features/usage/error-reports')
+ const router = { isServer: false }
+ const tracing = traceRouter(router as never)
+ await startErrorReporting({ sentryDsn: 'https://key@o1.ingest.sentry.io/2' })
+ await tracing
+ expect(addIntegration).toHaveBeenCalledWith({ name: 'Router', router })
+ })
+
+ it('leaves router tracing off without a DSN', async () => {
+ vi.stubGlobal('window', {})
+ const addIntegration = vi.fn()
+ vi.doMock('@sentry/tanstackstart-react', () => ({ init: vi.fn(), addIntegration }))
+ const { startErrorReporting, traceRouter } = await import('#/features/usage/error-reports')
+ const tracing = traceRouter({ isServer: false } as never)
+ await startErrorReporting({})
+ await tracing
+ expect(addIntegration).not.toHaveBeenCalled()
})
})
diff --git a/src/features/usage/__tests__/usage.test.ts b/src/features/usage/__tests__/usage.test.ts
index d5bc5f2..f6a6b2e 100644
--- a/src/features/usage/__tests__/usage.test.ts
+++ b/src/features/usage/__tests__/usage.test.ts
@@ -2,38 +2,63 @@ import { describe, expect, it } from 'vitest'
import { chain } from '#/features/nodes/__tests__/harness'
import { productRegistry } from '#/features/nodes/registry'
import { pipelineShape } from '#/features/usage/pipeline-shape'
-import { scrubSentryEvent, scrubText } from '#/features/usage/scrub'
+import { scrubSentryEvent, scrubSentryLog, scrubText } from '#/features/usage/scrub'
import { createAnalytics, POSTHOG_OPTIONS } from '#/features/usage/usage'
function fakePostHog() {
const captured: { event: string; properties: Record }[] = []
const inits: { key: string; options: Record }[] = []
+ const registered: Record[] = []
return {
captured,
inits,
+ registered,
client: {
init: (key: string, options: Record) => inits.push({ key, options }),
capture: (event: string, properties: Record) =>
captured.push({ event, properties }),
+ register: (properties: Record) => registered.push(properties),
},
}
}
describe('analytics', () => {
- it('starts PostHog cookieless, without person profiles, autocapture or replay', () => {
+ it('starts PostHog as the TanStack Start guide shows, cookieless and without person profiles', () => {
const posthog = fakePostHog()
createAnalytics({ key: 'phc_test', host: 'https://eu.i.posthog.com', posthog: posthog.client })
expect(posthog.inits).toHaveLength(1)
- expect(posthog.inits[0].options).toMatchObject({
+ const { options } = posthog.inits[0]
+ expect(options).toMatchObject({
api_host: 'https://eu.i.posthog.com',
+ defaults: '2026-05-30',
cookieless_mode: 'always',
person_profiles: 'never',
- autocapture: false,
- capture_pageview: false,
- capture_pageleave: false,
disable_session_recording: true,
- persistence: 'memory',
})
+ // Pageviews, page leaves, clicks, heatmaps and web vitals are captured automatically.
+ expect(options.capture_pageview).not.toBe(false)
+ expect(options.autocapture).not.toBe(false)
+ expect(options).toMatchObject({
+ capture_heatmaps: true,
+ capture_performance: { web_vitals: true },
+ })
+ })
+
+ // File names are shown on screen, so autocapture records which element was used, never its text.
+ it('keeps element text and attributes out of autocaptured events', () => {
+ const posthog = fakePostHog()
+ createAnalytics({ key: 'phc_test', posthog: posthog.client })
+ expect(posthog.inits[0].options).toMatchObject({
+ mask_all_text: true,
+ mask_all_element_attributes: true,
+ capture_exceptions: false,
+ })
+ })
+
+ it('labels every event with the app version', () => {
+ const posthog = fakePostHog()
+ createAnalytics({ key: 'phc_test', appVersion: '1.4.0', posthog: posthog.client })
+ expect(posthog.registered).toEqual([{ app_version: '1.4.0' }])
})
// PostHog hashes the IP into the daily cookieless ID and drops cookieless events without one.
@@ -72,6 +97,19 @@ describe('analytics', () => {
expect(posthog.captured.map(({ event }) => event)).toEqual(['run_finished'])
})
+ it('sends node removals, engine problems and colour mode changes', () => {
+ const posthog = fakePostHog()
+ const analytics = createAnalytics({ key: 'k', posthog: posthog.client })
+ analytics.track('node_removed', { nodeType: 'resize', method: 'keyboard' })
+ analytics.track('engine_unavailable', { reason: 'insecure' })
+ analytics.track('colour_mode_changed', { mode: 'light' })
+ expect(posthog.captured).toEqual([
+ { event: 'node_removed', properties: { nodeType: 'resize', method: 'keyboard' } },
+ { event: 'engine_unavailable', properties: { reason: 'insecure' } },
+ { event: 'colour_mode_changed', properties: { mode: 'light' } },
+ ])
+ })
+
it('refuses events carrying anything outside their schema, such as a file name', () => {
const posthog = fakePostHog()
const analytics = createAnalytics({ key: 'k', posthog: posthog.client })
@@ -122,4 +160,17 @@ describe('scrub', () => {
breadcrumbs: [{ message: 'dropped [file]' }],
})
})
+
+ it('scrubs Sentry log messages and their attributes', () => {
+ const log = scrubSentryLog({
+ level: 'warn',
+ message: 'Could not read cat.png',
+ attributes: { 'sentry.message.parameter.0': 'dog.webp', count: 2 },
+ })
+ expect(log).toEqual({
+ level: 'warn',
+ message: 'Could not read [file]',
+ attributes: { 'sentry.message.parameter.0': '[file]', count: 2 },
+ })
+ })
})
diff --git a/src/features/usage/constants.ts b/src/features/usage/constants.ts
new file mode 100644
index 0000000..474341f
--- /dev/null
+++ b/src/features/usage/constants.ts
@@ -0,0 +1,13 @@
+/** The page meta tag the server writes the public settings into, for the browser to read early. */
+export const PUBLIC_CONFIG_META = 'hexlode-config'
+
+/** PostHog's recommended defaults as of this date, as its TanStack Start guide sets them. */
+export const POSTHOG_DEFAULTS = '2026-05-30'
+
+export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
+
+/**
+ * The share of page loads, navigations and server requests Sentry traces. A fifth keeps a steady
+ * view of performance across the month within the free plan's span quota.
+ */
+export const TRACES_SAMPLE_RATE = 0.2
diff --git a/src/features/usage/error-reports.ts b/src/features/usage/error-reports.ts
index 21e55ea..3744618 100644
--- a/src/features/usage/error-reports.ts
+++ b/src/features/usage/error-reports.ts
@@ -1,30 +1,45 @@
/**
- * Error reports. Sentry runs without personal data or replay; file names are removed from
- * messages, exceptions and breadcrumbs before sending.
+ * Error reports, logs and performance tracing, set up as Sentry's TanStack Start guide shows but
+ * started from the settings the server hands the page. Sentry runs without personal data or
+ * replay; file names are removed from messages, exceptions, breadcrumbs and logs before sending.
*/
-import { scrubSentryEvent, scrubText } from '#/features/usage/scrub'
+import type { AnyRouter } from '@tanstack/react-router'
+
+import { TRACES_SAMPLE_RATE } from '#/features/usage/constants'
+import { scrubSentryEvent, scrubSentryLog, scrubText } from '#/features/usage/scrub'
import type { PublicConfig } from '#/features/usage/types'
+type SentryModule = typeof import('@sentry/tanstackstart-react')
+
let started = false
+let markReady: (sentry: SentryModule | undefined) => void = () => {}
+/** Settles once Sentry has started, or with nothing when it is off or blocked. */
+const ready = new Promise((resolve) => {
+ markReady = resolve
+})
export async function startErrorReporting(config: PublicConfig) {
- const dsn = config.sentryDsn
- if (started || !dsn || typeof window === 'undefined') return
+ if (started || typeof window === 'undefined') return
started = true
- let Sentry: typeof import('@sentry/tanstackstart-react')
+ const dsn = config.sentryDsn
+ if (!dsn) return markReady(undefined)
+ let Sentry: SentryModule
try {
Sentry = await import('@sentry/tanstackstart-react')
} catch {
// A content blocker stopped Sentry. The app works the same without error reports.
- return
+ return markReady(undefined)
}
Sentry.init({
dsn,
+ release: config.appVersion,
+ environment: import.meta.env?.PROD ? 'production' : 'development',
sendDefaultPii: false,
- tracesSampleRate: 0,
- replaysSessionSampleRate: 0,
- replaysOnErrorSampleRate: 0,
+ enableLogs: true,
+ tracesSampleRate: TRACES_SAMPLE_RATE,
+ integrations: [Sentry.consoleLoggingIntegration({ levels: ['warn', 'error'] })],
beforeSend: (event) => scrubSentryEvent(event),
+ beforeSendLog: (log) => scrubSentryLog(log),
beforeBreadcrumb: (breadcrumb) => {
if (breadcrumb.category === 'console' || breadcrumb.category?.startsWith('ui.')) return null
return breadcrumb.message
@@ -32,4 +47,12 @@ export async function startErrorReporting(config: PublicConfig) {
: breadcrumb
},
})
+ markReady(Sentry)
+}
+
+/** Traces page loads and navigations by route once Sentry has started in the browser. */
+export async function traceRouter(router: AnyRouter) {
+ if (router.isServer) return
+ const Sentry = await ready
+ Sentry?.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router))
}
diff --git a/src/features/usage/events.ts b/src/features/usage/events.ts
index 0874276..74eb3ec 100644
--- a/src/features/usage/events.ts
+++ b/src/features/usage/events.ts
@@ -4,6 +4,8 @@
*/
import { z } from 'zod'
+import { COLOUR_MODES } from '#/features/theme/constants'
+
const count = z.number().int().nonnegative()
const code = z.string().regex(/^[a-z0-9_]{1,40}$/)
const nodeType = z.string().regex(/^[a-z0-9.-]{1,40}$/)
@@ -112,6 +114,10 @@ export const EVENTS = {
description: 'A node was added to the canvas, and how.',
properties: z.object({ nodeType, method: z.enum(['drag', 'click', 'search']) }).strict(),
},
+ node_removed: {
+ description: 'A node was removed from the canvas, and how.',
+ properties: z.object({ nodeType, method: z.enum(['keyboard', 'menu', 'inspector']) }).strict(),
+ },
connection_checked: {
description: 'A connection was made or refused: the two node types and the decision.',
properties: z
@@ -147,6 +153,15 @@ export const EVENTS = {
.object({ action: z.enum(['budget', 'clear']), budgetGigabytes: z.number().optional() })
.strict(),
},
+ engine_unavailable: {
+ description:
+ 'A page could not run the engine: it was opened over plain HTTP, or the browser lacks Web Workers or the Origin Private File System.',
+ properties: z.object({ reason: z.enum(['insecure', 'unsupported']) }).strict(),
+ },
+ colour_mode_changed: {
+ description: 'The colour mode was changed.',
+ properties: z.object({ mode: z.enum(COLOUR_MODES) }).strict(),
+ },
} as const
export type AnalyticsEventName = keyof typeof EVENTS
diff --git a/src/features/usage/public-config.ts b/src/features/usage/public-config.ts
index 2b7ac78..a5fe9b2 100644
--- a/src/features/usage/public-config.ts
+++ b/src/features/usage/public-config.ts
@@ -11,5 +11,6 @@ export const getPublicConfig = createServerFn({ method: 'GET' }).handler(() =>
VITE_POSTHOG_KEY: process.env.VITE_POSTHOG_KEY || import.meta.env.VITE_POSTHOG_KEY,
VITE_POSTHOG_HOST: process.env.VITE_POSTHOG_HOST || import.meta.env.VITE_POSTHOG_HOST,
VITE_SENTRY_DSN: process.env.VITE_SENTRY_DSN || import.meta.env.VITE_SENTRY_DSN,
+ HEXLODE_VERSION: process.env.HEXLODE_VERSION,
}),
)
diff --git a/src/features/usage/scrub.ts b/src/features/usage/scrub.ts
index 6ac860b..454cf90 100644
--- a/src/features/usage/scrub.ts
+++ b/src/features/usage/scrub.ts
@@ -41,3 +41,23 @@ export function scrubSentryEvent(event: T): T {
}
return scrubbed
}
+
+interface SentryLikeLog {
+ message?: unknown
+ attributes?: Record
+}
+
+/** Logs carry console arguments as attributes, so both the message and the attributes are scrubbed. */
+export function scrubSentryLog(log: T): T {
+ const scrubbed: T = { ...log }
+ if (typeof log.message === 'string') scrubbed.message = scrubText(log.message)
+ if (log.attributes) {
+ scrubbed.attributes = Object.fromEntries(
+ Object.entries(log.attributes).map(([key, value]) => [
+ key,
+ typeof value === 'string' ? scrubText(value) : value,
+ ]),
+ )
+ }
+ return scrubbed
+}
diff --git a/src/features/usage/types.ts b/src/features/usage/types.ts
index 58aa8f1..7b5b0f4 100644
--- a/src/features/usage/types.ts
+++ b/src/features/usage/types.ts
@@ -7,4 +7,6 @@ export interface PublicConfig {
posthogKey?: string
posthogHost?: string
sentryDsn?: string
+ /** The running image's version, from `HEXLODE_VERSION`. Labels events and error reports. */
+ appVersion?: string
}
diff --git a/src/features/usage/usage.ts b/src/features/usage/usage.ts
index 326f6c6..3a74344 100644
--- a/src/features/usage/usage.ts
+++ b/src/features/usage/usage.ts
@@ -1,47 +1,45 @@
/**
- * The only way the app sends product analytics. PostHog runs cookieless, without person profiles,
- * autocapture or session replay. Each event is checked against its schema before it is sent.
+ * The only way the app sends product analytics. PostHog starts as its TanStack Start guide shows,
+ * in cookieless mode and without person profiles. It captures pageviews, page leaves, clicks,
+ * heatmaps and web vitals by itself, with element text and attributes masked because file names are
+ * shown on screen. Session replay stays off. The app's own events are checked against their schema
+ * before they are sent.
*/
+import { DEFAULT_POSTHOG_HOST, POSTHOG_DEFAULTS } from '#/features/usage/constants'
import { type AnalyticsEventName, EVENTS, type EventProperties } from '#/features/usage/events'
import type { PublicConfig } from '#/features/usage/types'
export interface PostHogLike {
init(key: string, options: Record): unknown
capture(event: string, properties: Record): unknown
+ register(properties: Record): unknown
}
export const POSTHOG_OPTIONS = {
+ defaults: POSTHOG_DEFAULTS,
cookieless_mode: 'always',
person_profiles: 'never',
- persistence: 'memory',
- disable_persistence: true,
- autocapture: false,
- capture_pageview: false,
- capture_pageleave: false,
- capture_heatmaps: false,
- capture_dead_clicks: false,
+ mask_all_text: true,
+ mask_all_element_attributes: true,
+ capture_heatmaps: true,
+ capture_performance: { web_vitals: true },
+ // Sentry reports errors, with file names removed first.
capture_exceptions: false,
- capture_performance: false,
- rageclick: false,
disable_session_recording: true,
- disable_surveys: true,
- disable_product_tours: true,
- disable_web_experiments: true,
- disable_external_dependency_loading: true,
- advanced_disable_flags: true,
- mask_personal_data_properties: true,
} as const
export interface AnalyticsOptions {
key?: string
host?: string
+ appVersion?: string
posthog: PostHogLike
}
-export function createAnalytics({ key, host, posthog }: AnalyticsOptions) {
+export function createAnalytics({ key, host, appVersion, posthog }: AnalyticsOptions) {
const enabled = Boolean(key)
if (key) {
- posthog.init(key, { ...POSTHOG_OPTIONS, api_host: host || 'https://us.i.posthog.com' })
+ posthog.init(key, { ...POSTHOG_OPTIONS, api_host: host || DEFAULT_POSTHOG_HOST })
+ if (appVersion) posthog.register({ app_version: appVersion })
}
return {
track(event: E, properties: EventProperties) {
@@ -74,6 +72,7 @@ export function startAnalytics(config: PublicConfig) {
instance = createAnalytics({
key,
host: config.posthogHost,
+ appVersion: config.appVersion,
posthog: posthog as unknown as PostHogLike,
})
} catch {
diff --git a/src/features/usage/validators.ts b/src/features/usage/validators.ts
index 626ab7c..ed79216 100644
--- a/src/features/usage/validators.ts
+++ b/src/features/usage/validators.ts
@@ -5,14 +5,36 @@ import type { PublicConfig } from '#/features/usage/types'
const optionalText = z.string().trim().min(1).optional().catch(undefined)
const optionalUrl = z.url().optional().catch(undefined)
+function withoutEmpty(config: PublicConfig): PublicConfig {
+ return Object.fromEntries(
+ Object.entries(config).filter(([, value]) => value !== undefined),
+ ) as PublicConfig
+}
+
/** Picks the public settings out of an environment. Empty or malformed values are left out. */
export function parsePublicConfig(env: Record): PublicConfig {
- const config: PublicConfig = {
+ return withoutEmpty({
posthogKey: optionalText.parse(env.VITE_POSTHOG_KEY),
posthogHost: optionalUrl.parse(env.VITE_POSTHOG_HOST),
sentryDsn: optionalUrl.parse(env.VITE_SENTRY_DSN),
+ appVersion: optionalText.parse(env.HEXLODE_VERSION),
+ })
+}
+
+const publicConfigSchema = z.object({
+ posthogKey: optionalText,
+ posthogHost: optionalUrl,
+ sentryDsn: optionalUrl,
+ appVersion: optionalText,
+})
+
+/** Reads the settings the server wrote into the page. Anything malformed is left out. */
+export function parsePublicConfigJson(text: string | null | undefined): PublicConfig {
+ if (!text) return {}
+ try {
+ const parsed = publicConfigSchema.safeParse(JSON.parse(text))
+ return parsed.success ? withoutEmpty(parsed.data) : {}
+ } catch {
+ return {}
}
- return Object.fromEntries(
- Object.entries(config).filter(([, value]) => value !== undefined),
- ) as PublicConfig
}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 96e5421..ba554a0 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -294,10 +294,11 @@ export const routeTree = rootRouteImport
._addFileTypes()
import type { getRouter } from './router.tsx'
-import type { createStart } from '@tanstack/react-start'
+import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited>
+ config: Awaited>
}
}
diff --git a/src/router.tsx b/src/router.tsx
index 324a735..11653fb 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -1,5 +1,7 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
+
+import { traceRouter } from '#/features/usage/error-reports'
import { getContext } from './integrations/tanstack-query/root-provider'
import { routeTree } from './routeTree.gen'
@@ -15,6 +17,7 @@ export function getRouter() {
})
setupRouterSsrQueryIntegration({ router, queryClient: context.queryClient })
+ void traceRouter(router)
return router
}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index ac19161..d68096f 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -5,15 +5,13 @@ import type { QueryClient } from '@tanstack/react-query'
import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
-import { useEffect } from 'react'
import { SiteFrame } from '#/features/app-shell/site-frame'
import { COLOUR_MODE_SCRIPT } from '#/features/theme/colour-mode'
import { useColourMode } from '#/features/theme/colour-mode-menu'
import { DEFAULT_COLOUR_MODE } from '#/features/theme/constants'
import { hexlodeTheme } from '#/features/theme/hexlode'
-import { startErrorReporting } from '#/features/usage/error-reports'
+import { PUBLIC_CONFIG_META } from '#/features/usage/constants'
import { getPublicConfig } from '#/features/usage/public-config'
-import { startAnalytics } from '#/features/usage/usage'
import { RouterLink } from '#/lib/router-link'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import appCss from '../styles.css?url'
@@ -26,7 +24,7 @@ export const Route = createRootRouteWithContext()({
// Read once per visit on the server, so the deployment's environment sets the keys.
loader: () => getPublicConfig(),
staleTime: Number.POSITIVE_INFINITY,
- head: () => ({
+ head: ({ loaderData }) => ({
meta: [
{
charSet: 'utf-8',
@@ -43,6 +41,8 @@ export const Route = createRootRouteWithContext()({
content:
'Convert, compress, resize, crop and clean images in your browser, or build batch pipelines in the Studio. Works on your device, with no upload needed.',
},
+ // src/client.tsx reads this to start analytics and error reports before hydration.
+ { name: PUBLIC_CONFIG_META, content: JSON.stringify(loaderData ?? {}) },
],
links: [
{
@@ -56,11 +56,6 @@ export const Route = createRootRouteWithContext()({
})
function RootDocument({ children }: { children: React.ReactNode }) {
- const config = Route.useLoaderData()
- useEffect(() => {
- void startAnalytics(config)
- void startErrorReporting(config)
- }, [config])
const mode = useColourMode()
return (
// The inline script sets the colour mode before paint, so the attribute can differ from the
diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx
index b992c08..8e514e9 100644
--- a/src/routes/privacy.tsx
+++ b/src/routes/privacy.tsx
@@ -57,9 +57,11 @@ function Privacy() {
What Hexlode measures
Product analytics run without cookies and without identifying you. Your IP address is
- turned into an anonymous ID that changes daily, then discarded. Events contain only
- counts, timings, node types, settings and error codes. They never contain file names,
- paths, pixels, image metadata or text you type.
+ turned into an anonymous ID that changes daily, then discarded. Pages you open, where
+ you click and how fast pages load are recorded, with the text on screen left out, and
+ there is no screen recording. The events below contain only counts, timings, node types,
+ settings and error codes. They never contain file names, paths, pixels, image metadata
+ or text you type.
{Object.entries(EVENTS).map(([name, event]) => (
@@ -70,8 +72,9 @@ function Privacy() {
Error reports
- When something breaks, an error report with the technical cause is sent. File names are
- removed from it, and it contains no personal data and no screen recording.
+ When something breaks, an error report with the technical cause is sent, along with
+ warnings the app logs and timings of how long pages and requests took. File names are
+ removed from them, and they contain no personal data and no screen recording.
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..7b3f480
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,11 @@
+import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'
+import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
+
+// Sentry itself starts from instrument.server.mjs, loaded with `node --import`.
+export default createServerEntry(
+ wrapFetchWithSentry({
+ fetch(request: Request) {
+ return handler.fetch(request)
+ },
+ }),
+)
diff --git a/src/start.ts b/src/start.ts
new file mode 100644
index 0000000..e06b688
--- /dev/null
+++ b/src/start.ts
@@ -0,0 +1,11 @@
+import {
+ sentryGlobalFunctionMiddleware,
+ sentryGlobalRequestMiddleware,
+} from '@sentry/tanstackstart-react'
+import { createStart } from '@tanstack/react-start'
+
+// Sentry's middleware comes first, so it sees every error from requests and server functions.
+export const startInstance = createStart(() => ({
+ requestMiddleware: [sentryGlobalRequestMiddleware],
+ functionMiddleware: [sentryGlobalFunctionMiddleware],
+}))
diff --git a/vite.config.ts b/vite.config.ts
index fad8663..dfde595 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from 'node:fs'
import babel from '@rolldown/plugin-babel'
+import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'
import tailwindcss from '@tailwindcss/vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
@@ -57,6 +58,10 @@ const config = defineConfig({
viteReact(),
babel({ presets: [reactCompilerPreset()] }),
codecLicences(),
+ // Last, as Sentry's guide asks. Uploads source maps when SENTRY_AUTH_TOKEN, SENTRY_ORG and
+ // SENTRY_PROJECT are set at build time, then deletes them from the output. The tunnel sends
+ // browser reports through this server, at a path generated per build, past content blockers.
+ sentryTanstackStart({ tunnelRoute: true, telemetry: false }),
],
})
From ef1ad229d401c72b4ec1051b0258612d09a7d1bc Mon Sep 17 00:00:00 2001
From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:53:24 +0530
Subject: [PATCH 4/5] ci: deploy Docker images only on releases
- Keep the `main` image for unreleased merges
- Publish `latest` and deploy via Dokploy on releases
- Update deployment documentation
---
.github/workflows/docker.yml | 18 +++++++++++-------
DEPLOY.md | 15 +++++++++------
README.md | 3 ++-
implementation.md | 9 +++++----
4 files changed, 27 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 4c1fab0..b322f36 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -1,9 +1,10 @@
name: Docker image
-# Publishes ghcr.io//hexlode for Dokploy to pull, then calls the Dokploy deploy webhook.
-# `main` updates the `latest` tag; a `v1.2.3` tag, which Release Please starts this workflow for,
-# publishes `1.2.3` and `1.2`. 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.
+# 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]
@@ -46,8 +47,11 @@ jobs:
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
@@ -138,8 +142,8 @@ jobs:
deploy:
name: deploy to Dokploy
needs: image
- # Only `main` deploys, and only once the webhook secret is 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:
diff --git a/DEPLOY.md b/DEPLOY.md
index 7b92604..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 release is made: merging the Release Please pull request tags `v1.2.3`. |
+| `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`
@@ -106,10 +107,12 @@ 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 calls the application's Dokploy deploy webhook once the new image is
-pushed, and Dokploy pulls `latest` and redeploys. Copy the webhook from the application's
+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.
@@ -149,7 +152,7 @@ Set these up in Sentry itself:
## 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
diff --git a/README.md b/README.md
index 7f21088..8081278 100644
--- a/README.md
+++ b/README.md
@@ -61,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
diff --git a/implementation.md b/implementation.md
index 7835fdf..01163ff 100644
--- a/implementation.md
+++ b/implementation.md
@@ -152,10 +152,11 @@ removed.
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 and deploys the image, since the pull request already ran the checks;
+ 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 and publishes the versioned image.
+ there; merging it tags the version, writes the changelog, publishes `latest` and the version tags, and
+ deploys.
## Analytics
@@ -180,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 calls
-the Dokploy deploy webhook on the maintainer's VPS. 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`.
From 7870ea70e3c937d9bacc9dcf91a028642cc9d914 Mon Sep 17 00:00:00 2001
From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com>
Date: Mon, 28 Sep 2026 19:01:16 +0530
Subject: [PATCH 5/5] feat(studio): keep drafts per tab and recover unsaved
changes
- Add per-tab session drafts with one-day recovery for unsaved work
- Add new/save controls and confirmation before discarding changes
- Document draft storage and privacy behavior
---
CONTEXT.md | 5 +-
idea.md | 9 +-
implementation.md | 2 +-
.../pipelines/__tests__/draft.test.ts | 127 +++++++++++++++---
src/features/pipelines/constants.ts | 10 +-
src/features/pipelines/draft.ts | 126 ++++++++++++++---
src/features/pipelines/validators.ts | 6 +
src/features/studio/studio-dialogs.tsx | 32 ++++-
src/features/studio/studio-page.tsx | 97 ++++++++++---
src/lib/__tests__/format.test.ts | 11 +-
src/lib/format.ts | 12 ++
src/routes/privacy.tsx | 2 +-
12 files changed, 369 insertions(+), 70 deletions(-)
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/idea.md b/idea.md
index 928c8e0..251a229 100644
--- a/idea.md
+++ b/idea.md
@@ -119,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 01163ff..940e2a6 100644
--- a/implementation.md
+++ b/implementation.md
@@ -53,7 +53,7 @@ Active phase: **Phase 1**.
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
diff --git a/src/features/pipelines/__tests__/draft.test.ts b/src/features/pipelines/__tests__/draft.test.ts
index b9b85fb..f0d46af 100644
--- a/src/features/pipelines/__tests__/draft.test.ts
+++ b/src/features/pipelines/__tests__/draft.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
-import { STUDIO_DRAFT_KEY } from '#/features/pipelines/constants'
+import {
+ DRAFT_RECOVERY_MAX_AGE_MS,
+ LEGACY_STUDIO_DRAFT_KEY,
+ STUDIO_DRAFT_KEY,
+ STUDIO_RECOVERY_KEY,
+} from '#/features/pipelines/constants'
import { createDraftStore } from '#/features/pipelines/draft'
function memoryStorage() {
@@ -18,37 +23,119 @@ const pipeline = {
connections: [],
}
+/** One browser: storage shared by its tabs, a clock, and a way to open tabs. */
+function browser() {
+ const shared = memoryStorage()
+ let time = 1_000_000
+ let ids = 0
+ return {
+ shared,
+ advance: (ms: number) => {
+ time += ms
+ },
+ tab(storage = memoryStorage()) {
+ return {
+ storage,
+ drafts: createDraftStore({
+ tab: storage,
+ browser: shared,
+ now: () => time,
+ createId: () => `tab-${++ids}`,
+ }),
+ }
+ },
+ }
+}
+
describe('studio draft', () => {
it('has no draft until one is written', () => {
- expect(createDraftStore(memoryStorage()).read()).toBeNull()
+ expect(browser().tab().drafts.read()).toBeNull()
})
- it('keeps the pipeline being edited, with its name and saved id, across reloads', () => {
- const storage = memoryStorage()
- createDraftStore(storage).write({ name: 'Shop', pipeline, savedId: 'abc' })
- expect(createDraftStore(storage).read()).toEqual({ name: 'Shop', pipeline, savedId: 'abc' })
+ it('keeps the pipeline being edited, with its name, saved id and changes, across reloads', () => {
+ const b = browser()
+ const tab = b.tab()
+ tab.drafts.write({ name: 'Shop', pipeline, savedId: 'abc', dirty: true })
+ const reloaded = b.tab(tab.storage)
+ expect(reloaded.drafts.read()).toEqual({ name: 'Shop', pipeline, savedId: 'abc', dirty: true })
})
- it('forgets the draft when cleared', () => {
- const storage = memoryStorage()
- const drafts = createDraftStore(storage)
- drafts.write({ name: 'Shop', pipeline, savedId: null })
- drafts.clear()
- expect(drafts.read()).toBeNull()
- expect(storage.values.has(STUDIO_DRAFT_KEY)).toBe(false)
+ it('starts a new tab without a draft, so the Studio offers the templates', () => {
+ const b = browser()
+ b.tab().drafts.write({ name: 'Shop', pipeline, savedId: null, dirty: true })
+ expect(b.tab().drafts.read()).toBeNull()
+ })
+
+ it('offers the last unsaved pipeline to a new tab for a day', () => {
+ const b = browser()
+ b.tab().drafts.write({ name: 'Shop', pipeline, savedId: null, dirty: true })
+ b.advance(60_000)
+ expect(b.tab().drafts.recoverable()).toEqual({
+ name: 'Shop',
+ pipeline,
+ savedId: null,
+ dirty: true,
+ updatedAt: 1_000_000,
+ })
+ b.advance(DRAFT_RECOVERY_MAX_AGE_MS)
+ expect(b.tab().drafts.recoverable()).toBeNull()
+ expect(b.shared.values.has(STUDIO_RECOVERY_KEY)).toBe(false)
+ })
+
+ it('offers nothing once the tab that made the changes saves them', () => {
+ const b = browser()
+ const tab = b.tab()
+ tab.drafts.write({ name: 'Shop', pipeline, savedId: null, dirty: true })
+ tab.drafts.write({ name: 'Shop', pipeline, savedId: 'abc', dirty: false })
+ expect(b.tab().drafts.recoverable()).toBeNull()
+ })
+
+ it('keeps offering one tab’s unsaved changes when another tab saves its own', () => {
+ const b = browser()
+ b.tab().drafts.write({ name: 'Shop', pipeline, savedId: null, dirty: true })
+ b.tab().drafts.write({ name: 'Other', pipeline, savedId: 'xyz', dirty: false })
+ expect(b.tab().drafts.recoverable()?.name).toBe('Shop')
+ })
+
+ it('forgets the recovered pipeline when discarded', () => {
+ const b = browser()
+ b.tab().drafts.write({ name: 'Shop', pipeline, savedId: null, dirty: true })
+ b.tab().drafts.discardRecovery()
+ expect(b.tab().drafts.recoverable()).toBeNull()
+ })
+
+ it('forgets the tab’s draft when cleared', () => {
+ const b = browser()
+ const tab = b.tab()
+ tab.drafts.write({ name: 'Shop', pipeline, savedId: null })
+ tab.drafts.clear()
+ expect(tab.drafts.read()).toBeNull()
+ expect(tab.storage.values.has(STUDIO_DRAFT_KEY)).toBe(false)
+ })
+
+ // Earlier versions kept one draft for the whole browser, which every new tab reopened.
+ it('removes the old browser-wide draft', () => {
+ const b = browser()
+ b.shared.setItem(LEGACY_STUDIO_DRAFT_KEY, JSON.stringify({ name: 'Old', pipeline }))
+ b.tab()
+ expect(b.shared.values.has(LEGACY_STUDIO_DRAFT_KEY)).toBe(false)
})
it('ignores a damaged draft', () => {
- const storage = memoryStorage()
- storage.setItem(STUDIO_DRAFT_KEY, '{"name": 3')
- expect(createDraftStore(storage).read()).toBeNull()
- storage.setItem(STUDIO_DRAFT_KEY, JSON.stringify({ name: 'x', pipeline: { nodes: 'no' } }))
- expect(createDraftStore(storage).read()).toBeNull()
+ const b = browser()
+ const tab = b.tab()
+ tab.storage.setItem(STUDIO_DRAFT_KEY, '{"name": 3')
+ expect(tab.drafts.read()).toBeNull()
+ tab.storage.setItem(STUDIO_DRAFT_KEY, JSON.stringify({ name: 'x', pipeline: { nodes: 'no' } }))
+ expect(tab.drafts.read()).toBeNull()
+ b.shared.setItem(STUDIO_RECOVERY_KEY, '{"updatedAt": "soon"}')
+ expect(tab.drafts.recoverable()).toBeNull()
})
it('works without storage', () => {
- const drafts = createDraftStore(undefined)
- drafts.write({ name: 'Shop', pipeline, savedId: null })
+ const drafts = createDraftStore({ tab: undefined, browser: undefined })
+ drafts.write({ name: 'Shop', pipeline, savedId: null, dirty: true })
expect(drafts.read()).toBeNull()
+ expect(drafts.recoverable()).toBeNull()
})
})
diff --git a/src/features/pipelines/constants.ts b/src/features/pipelines/constants.ts
index 436d566..343625b 100644
--- a/src/features/pipelines/constants.ts
+++ b/src/features/pipelines/constants.ts
@@ -6,5 +6,13 @@ export const SAVED_PIPELINES_KEY = 'hexlode:pipelines'
export const MAX_PIPELINE_NAME_LENGTH = 80
export const SAVE_NOTICE =
'Saved in this browser only. Clearing site data deletes it; export a .hexlode file to keep a backup.'
-/** The pipeline open in the Studio, kept so a reload does not lose unsaved work. */
+/** The pipeline open in this tab's Studio, in session storage, so a reload does not lose it. */
export const STUDIO_DRAFT_KEY = 'hexlode:studio-draft'
+/** Identifies a tab, in its session storage, so it only clears the recovery copy it wrote. */
+export const STUDIO_TAB_KEY = 'hexlode:studio-tab'
+/** The last pipeline with unsaved changes from any tab, in local storage, for a new tab to offer. */
+export const STUDIO_RECOVERY_KEY = 'hexlode:studio-recovery'
+/** A new tab offers unsaved changes for this long after they were made. */
+export const DRAFT_RECOVERY_MAX_AGE_MS = 24 * 60 * 60 * 1000
+/** Where earlier versions kept one draft for the whole browser, in local storage. */
+export const LEGACY_STUDIO_DRAFT_KEY = STUDIO_DRAFT_KEY
diff --git a/src/features/pipelines/draft.ts b/src/features/pipelines/draft.ts
index ca12084..bc31857 100644
--- a/src/features/pipelines/draft.ts
+++ b/src/features/pipelines/draft.ts
@@ -1,42 +1,130 @@
/**
- * The pipeline open in the Studio, written on every change so a reload or a crashed tab does not
- * lose it. It holds nodes, settings and the name, never images.
+ * The pipeline open in the Studio. Each tab keeps its own draft in session storage, so a reload
+ * keeps the pipeline while a new tab starts fresh. The last pipeline with unsaved changes is also
+ * kept in local storage for a day, for a new tab to offer after a tab was closed by mistake.
+ * Drafts hold nodes, settings and the name, never images.
*/
-import { STUDIO_DRAFT_KEY } from '#/features/pipelines/constants'
+import {
+ DRAFT_RECOVERY_MAX_AGE_MS,
+ LEGACY_STUDIO_DRAFT_KEY,
+ STUDIO_DRAFT_KEY,
+ STUDIO_RECOVERY_KEY,
+ STUDIO_TAB_KEY,
+} from '#/features/pipelines/constants'
import type { KeyValueStorage } from '#/features/pipelines/storage'
import type { NamedPipeline } from '#/features/pipelines/types'
-import { studioDraftSchema } from '#/features/pipelines/validators'
+import { recoverableDraftSchema, studioDraftSchema } from '#/features/pipelines/validators'
export interface StudioDraft extends NamedPipeline {
/** The saved pipeline this draft edits, if any. */
savedId: string | null
+ /** The draft has changes its saved pipeline lacks. */
+ dirty?: boolean
}
-export function createDraftStore(storage: KeyValueStorage | undefined) {
+export interface RecoverableDraft extends StudioDraft {
+ /** When the changes were made, in milliseconds since the epoch. */
+ updatedAt: number
+}
+
+export interface DraftStorageOptions {
+ /** This tab's session storage. */
+ tab: KeyValueStorage | undefined
+ /** Local storage, shared by every tab. */
+ browser: KeyValueStorage | undefined
+ now?: () => number
+ createId?: () => string
+}
+
+function readJson(storage: KeyValueStorage | undefined, key: string): unknown {
+ try {
+ const raw = storage?.getItem(key)
+ return raw ? JSON.parse(raw) : undefined
+ } catch {
+ return undefined
+ }
+}
+
+function writeJson(storage: KeyValueStorage | undefined, key: string, value: unknown) {
+ try {
+ storage?.setItem(key, JSON.stringify(value))
+ } catch {
+ // Storage is full or blocked; the draft is a convenience, so editing carries on.
+ }
+}
+
+function remove(storage: KeyValueStorage | undefined, key: string) {
+ try {
+ storage?.removeItem(key)
+ } catch {
+ // Blocked storage has nothing to remove.
+ }
+}
+
+export function createDraftStore({
+ tab,
+ browser,
+ now = () => Date.now(),
+ createId = () => crypto.randomUUID(),
+}: DraftStorageOptions) {
+ remove(browser, LEGACY_STUDIO_DRAFT_KEY)
+
+ const ownId = () => {
+ const existing = tab?.getItem(STUDIO_TAB_KEY)
+ if (existing) return existing
+ const id = createId()
+ try {
+ tab?.setItem(STUDIO_TAB_KEY, id)
+ } catch {
+ // Without a stored id, this tab's recovery copy simply stays until it expires.
+ }
+ return id
+ }
+
+ const readRecovery = () => {
+ const parsed = recoverableDraftSchema.safeParse(readJson(browser, STUDIO_RECOVERY_KEY))
+ return parsed.success ? parsed.data : null
+ }
+
return {
+ /** This tab's draft, or null in a new tab. */
read(): StudioDraft | null {
- try {
- const raw = storage?.getItem(STUDIO_DRAFT_KEY)
- const parsed = raw ? studioDraftSchema.safeParse(JSON.parse(raw)) : undefined
- return parsed?.success ? parsed.data : null
- } catch {
- return null
- }
+ const parsed = studioDraftSchema.safeParse(readJson(tab, STUDIO_DRAFT_KEY))
+ return parsed.success ? parsed.data : null
},
write(draft: StudioDraft) {
- try {
- storage?.setItem(STUDIO_DRAFT_KEY, JSON.stringify(draft))
- } catch {
- // Storage is full or blocked; the draft is a convenience, so editing carries on.
+ writeJson(tab, STUDIO_DRAFT_KEY, draft)
+ if (!tab || !browser) return
+ const id = ownId()
+ if (draft.dirty) {
+ writeJson(browser, STUDIO_RECOVERY_KEY, { ...draft, updatedAt: now(), tabId: id })
+ } else if (readRecovery()?.tabId === id) {
+ remove(browser, STUDIO_RECOVERY_KEY)
}
},
clear() {
- storage?.removeItem(STUDIO_DRAFT_KEY)
+ remove(tab, STUDIO_DRAFT_KEY)
+ },
+ /** The last unsaved pipeline from any tab, if it changed within the last day. */
+ recoverable(): RecoverableDraft | null {
+ const recovery = readRecovery()
+ if (!recovery) return null
+ if (now() - recovery.updatedAt > DRAFT_RECOVERY_MAX_AGE_MS) {
+ remove(browser, STUDIO_RECOVERY_KEY)
+ return null
+ }
+ const { tabId: _tabId, ...draft } = recovery
+ return draft
+ },
+ discardRecovery() {
+ remove(browser, STUDIO_RECOVERY_KEY)
},
}
}
-/** The draft store for this browser. Empty during server rendering. */
+/** The draft store for this tab. Empty during server rendering. */
export function draftStore() {
- return createDraftStore(typeof window === 'undefined' ? undefined : window.localStorage)
+ return typeof window === 'undefined'
+ ? createDraftStore({ tab: undefined, browser: undefined })
+ : createDraftStore({ tab: window.sessionStorage, browser: window.localStorage })
}
diff --git a/src/features/pipelines/validators.ts b/src/features/pipelines/validators.ts
index effcdb8..7bf028c 100644
--- a/src/features/pipelines/validators.ts
+++ b/src/features/pipelines/validators.ts
@@ -42,4 +42,10 @@ export const studioDraftSchema = z.object({
name: z.string().max(MAX_PIPELINE_NAME_LENGTH),
savedId: z.string().min(1).nullable(),
pipeline: pipelineSchema,
+ dirty: z.boolean().optional(),
+})
+
+export const recoverableDraftSchema = studioDraftSchema.extend({
+ updatedAt: z.number().int().nonnegative(),
+ tabId: z.string().min(1),
})
diff --git a/src/features/studio/studio-dialogs.tsx b/src/features/studio/studio-dialogs.tsx
index 2ab4af9..929b0df 100644
--- a/src/features/studio/studio-dialogs.tsx
+++ b/src/features/studio/studio-dialogs.tsx
@@ -1,4 +1,5 @@
import { Button } from '@astryxdesign/core/Button'
+import { Card } from '@astryxdesign/core/Card'
import { CommandPalette } from '@astryxdesign/core/CommandPalette'
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'
import { Divider } from '@astryxdesign/core/Divider'
@@ -12,12 +13,13 @@ import { HStack, VStack } from '@astryxdesign/core/Stack'
import { Heading, Text } from '@astryxdesign/core/Text'
import { TextInput } from '@astryxdesign/core/TextInput'
import { createStaticSource, type SearchableItem } from '@astryxdesign/core/Typeahead'
-import { FilePlus, FolderOpen, Upload } from 'lucide-react'
+import { FilePlus, FolderOpen, History, Upload } from 'lucide-react'
import { useEffect, useState } from 'react'
import { IconTile } from '#/features/app-shell/icon-tile'
import { GIGABYTE } from '#/features/engine/constants'
import type { NodeRegistry } from '#/features/engine/types'
import { MAX_PIPELINE_NAME_LENGTH, SAVE_NOTICE } from '#/features/pipelines/constants'
+import type { RecoverableDraft } from '#/features/pipelines/draft'
import { availableTemplates } from '#/features/pipelines/templates'
import type { SavedPipeline, Template } from '#/features/pipelines/types'
import { engineRuntime } from '#/features/runs/engine-runtime'
@@ -26,13 +28,16 @@ import { readSettings, writeSettings } from '#/features/settings/settings'
import { CATEGORIES, NODE_ICONS, toneOf } from '#/features/studio/node-ui'
import { PipelineSteps } from '#/features/studio/pipeline-steps'
import { track } from '#/features/usage/usage'
-import { formatBytes } from '#/lib/format'
+import { formatAge, formatBytes } from '#/lib/format'
export function TemplatePicker({
isOpen,
onOpenChange,
registry,
hasSaved,
+ recovery,
+ onRecover,
+ onDiscardRecovery,
onChoose,
onImport,
onOpenSaved,
@@ -42,6 +47,10 @@ export function TemplatePicker({
registry: NodeRegistry
/** The browser has saved pipelines to open. */
hasSaved: boolean
+ /** Unsaved changes from another tab, possibly closed, to continue with. */
+ recovery: RecoverableDraft | null
+ onRecover: (draft: RecoverableDraft) => void
+ onDiscardRecovery: () => void
onChoose: (template: Template) => void
onImport: () => void
onOpenSaved: () => void
@@ -56,6 +65,25 @@ export function TemplatePicker({
onOpenChange={onOpenChange}
/>
+ {recovery ? (
+
+
+
+
+
+ Continue “{recovery.name}”
+
+ Unsaved changes from another tab, {formatAge(Date.now() - recovery.updatedAt)}
+
+
+
+
+
+
+
+
+ ) : null}
{templates
.filter((template) => template.id !== 'blank')
diff --git a/src/features/studio/studio-page.tsx b/src/features/studio/studio-page.tsx
index 8d4b1fb..f8162fd 100644
--- a/src/features/studio/studio-page.tsx
+++ b/src/features/studio/studio-page.tsx
@@ -1,3 +1,4 @@
+import { AlertDialog } from '@astryxdesign/core/AlertDialog'
import { Banner } from '@astryxdesign/core/Banner'
import { Button } from '@astryxdesign/core/Button'
import { Center } from '@astryxdesign/core/Center'
@@ -10,18 +11,19 @@ import { Link } from '@astryxdesign/core/Link'
import { MoreMenu } from '@astryxdesign/core/MoreMenu'
import { Popover } from '@astryxdesign/core/Popover'
import { HStack, VStack } from '@astryxdesign/core/Stack'
+import { StatusDot } from '@astryxdesign/core/StatusDot'
import { Text } from '@astryxdesign/core/Text'
import { useToast } from '@astryxdesign/core/Toast'
import { useNavigate } from '@tanstack/react-router'
import { ReactFlowProvider, useReactFlow } from '@xyflow/react'
-import { CircleHelp, Download, Play, Redo2, Settings, Undo2 } from 'lucide-react'
+import { CircleHelp, Download, Play, Plus, Redo2, Save, Settings, Undo2 } from 'lucide-react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { describeEstimate } from '#/features/engine/estimate'
import type { FolderTarget } from '#/features/engine/opfs/run-stores'
import { pickOutputFolder } from '#/features/image-input/folder'
import { productRegistry } from '#/features/nodes/registry'
-import { draftStore } from '#/features/pipelines/draft'
+import { draftStore, type RecoverableDraft } from '#/features/pipelines/draft'
import {
exportPipelineFile,
importPipelineFile,
@@ -30,6 +32,7 @@ import {
validatePipeline,
} from '#/features/pipelines/pipeline-file'
import { pipelineStore } from '#/features/pipelines/storage'
+import type { NamedPipeline } from '#/features/pipelines/types'
import { QUICK_TOOL_GROUPS } from '#/features/quick-tools/tool-ui'
import { QUICK_TOOL_DEFINITIONS } from '#/features/quick-tools/tools'
import { EngineGate } from '#/features/runs/engine-unavailable'
@@ -119,7 +122,7 @@ function initialPipeline(savedPipelineId: string | undefined) {
return { named: saved, savedId: saved.id, dirty: false }
}
if (!savedPipelineId && draft && draft.savedId === null && usable(draft.pipeline)) {
- return { named: draft, savedId: null, dirty: true }
+ return { named: draft, savedId: null, dirty: draft.dirty ?? true }
}
return null
}
@@ -148,7 +151,11 @@ function Studio({
const showToast = useToast()
const navigate = useNavigate()
const importInput = useRef(null)
- const [dialog, setDialog] = useState<'templates' | 'save' | 'open' | 'settings' | null>(null)
+ const [dialog, setDialog] = useState<
+ 'templates' | 'discard' | 'save' | 'open' | 'settings' | null
+ >(null)
+ /** Unsaved changes from another tab, offered when this tab starts without a pipeline. */
+ const [recovery, setRecovery] = useState(null)
const [nodeRequest, setNodeRequest] = useState(null)
const [helpOpen, setHelpOpen] = useState(false)
const [libraryCollapsed, setLibraryCollapsed] = useState(false)
@@ -159,19 +166,20 @@ function Studio({
store.load(initial.named, initial.savedId, { dirty: initial.dirty })
requestAnimationFrame(() => void flow.fitView(FIT_VIEW))
} else {
+ setRecovery(draftStore().recoverable())
setDialog('templates')
}
session.refresh()
}, [savedPipelineId, session, store, flow])
- // Keep the pipeline being edited, so a reload or a closed tab does not lose it.
+ // Keep this tab's pipeline, so a reload does not lose it, and unsaved changes for a new tab to offer.
useEffect(() => {
let timer: ReturnType | undefined
const unsubscribe = store.subscribe(() => {
clearTimeout(timer)
timer = setTimeout(() => {
- const { name, pipeline, savedId } = store.getState()
- draftStore().write({ name, pipeline, savedId })
+ const { name, pipeline, savedId, dirty } = store.getState()
+ draftStore().write({ name, pipeline, savedId, dirty })
}, DRAFT_SAVE_DELAY_MS)
})
return () => {
@@ -273,6 +281,26 @@ function Studio({
await session.start({ folders })
}
+ /**
+ * Opens a pipeline in this tab and points the address at it: `?pipeline=` for a saved one,
+ * plain `/studio` otherwise. The draft is written first, so a reload opens the same pipeline.
+ */
+ const openInTab = (named: NamedPipeline, savedId: string | null, dirty = false) => {
+ draftStore().write({ ...named, savedId, dirty })
+ setDialog(null)
+ setRecovery(null)
+ if ((savedId ?? undefined) !== savedPipelineId) {
+ // The page reloads the pipeline from the draft once the address changes.
+ void navigate({ to: '/studio', search: { pipeline: savedId ?? undefined } })
+ return
+ }
+ store.load(named, savedId, { dirty })
+ session.refresh()
+ requestAnimationFrame(() => void flow.fitView(FIT_VIEW))
+ }
+
+ const startNew = () => setDialog(studio.dirty ? 'discard' : 'templates')
+
const save = (name: string) => {
const result = pipelineStore().save({
id: studio.savedId ?? undefined,
@@ -300,15 +328,12 @@ function Studio({
const importFile = async (file: File) => {
try {
const imported = importPipelineFile(await file.text(), registry)
- store.load(imported)
- session.refresh()
- setDialog(null)
+ openInTab(imported, null)
track('pipeline_file', {
action: 'import',
result: 'ok',
nodeCount: imported.pipeline.nodes.length,
})
- requestAnimationFrame(() => void flow.fitView(FIT_VIEW))
} catch (reason) {
const message =
reason instanceof PipelineFileError ? reason.message : 'The file could not be read.'
@@ -363,17 +388,30 @@ function Studio({
onClick={() => store.redo()}
isDisabled={!studio.canRedo}
/>
- }
+ variant="ghost"
size="sm"
+ onClick={startNew}
+ />
+ }
variant="ghost"
+ size="sm"
onClick={() => setDialog('save')}
/>
+ {studio.dirty ? (
+
+ ) : null}
setDialog('templates') },
+ { label: 'New pipeline…', onClick: startNew },
{ label: 'Open saved pipeline…', onClick: () => setDialog('open') },
{ type: 'divider' },
{ label: 'Import .hexlode file…', onClick: () => importInput.current?.click() },
@@ -538,19 +576,36 @@ function Studio({
onOpenChange={(open) => setDialog(open ? 'templates' : null)}
registry={registry}
hasSaved={saved.length > 0}
+ recovery={recovery}
+ onRecover={(draft) => {
+ const savedId = draft.savedId && pipelineStore().get(draft.savedId) ? draft.savedId : null
+ openInTab(draft, savedId, true)
+ }}
+ onDiscardRecovery={() => {
+ draftStore().discardRecovery()
+ setRecovery(null)
+ }}
onImport={() => importInput.current?.click()}
onOpenSaved={() => setDialog('open')}
onChoose={(template) => {
- store.load({
- name: template.id === 'blank' ? 'Untitled pipeline' : template.name,
- pipeline: template.pipeline,
- })
- session.refresh()
- setDialog(null)
+ openInTab(
+ {
+ name: template.id === 'blank' ? 'Untitled pipeline' : template.name,
+ pipeline: template.pipeline,
+ },
+ null,
+ )
track('template_chosen', { template: template.id })
- requestAnimationFrame(() => void flow.fitView(FIT_VIEW))
}}
/>
+ setDialog(open ? 'discard' : null)}
+ title="Start a new pipeline?"
+ description={`Your unsaved changes to “${studio.name}” will be lost. Save the pipeline first to keep them.`}
+ actionLabel="Discard changes"
+ onAction={() => setDialog('templates')}
+ />
{
diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts
index 2f591b5..5f73f6c 100644
--- a/src/lib/__tests__/format.test.ts
+++ b/src/lib/__tests__/format.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { formatChange } from '#/lib/format'
+import { formatAge, formatChange } from '#/lib/format'
describe('formatChange', () => {
it('shows how much smaller or larger a file got', () => {
@@ -18,3 +18,12 @@ describe('formatChange', () => {
expect(formatChange(0, 500)).toBe('—')
})
})
+
+describe('formatAge', () => {
+ it('says how long ago something happened, in the largest whole unit', () => {
+ expect(formatAge(20_000)).toBe('just now')
+ expect(formatAge(5 * 60_000)).toBe('5 minutes ago')
+ expect(formatAge(60 * 60_000)).toBe('1 hour ago')
+ expect(formatAge(23 * 60 * 60_000)).toBe('23 hours ago')
+ })
+})
diff --git a/src/lib/format.ts b/src/lib/format.ts
index f8dc1d6..a539b60 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -28,3 +28,15 @@ export function formatChange(before: number, after: number) {
const counter = new Intl.NumberFormat('en')
export const formatCount = (value: number) => counter.format(value)
+
+const relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'always' })
+
+/** "5 minutes ago" or "2 hours ago": how long ago something happened, in whole units. */
+export function formatAge(ms: number) {
+ const minutes = Math.floor(ms / 60_000)
+ if (minutes < 1) return 'just now'
+ if (minutes < 60) return relativeTime.format(-minutes, 'minute')
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return relativeTime.format(-hours, 'hour')
+ return relativeTime.format(-Math.floor(hours / 24), 'day')
+}
diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx
index 8e514e9..fae17bc 100644
--- a/src/routes/privacy.tsx
+++ b/src/routes/privacy.tsx
@@ -42,7 +42,7 @@ function Privacy() {
/>