From fcf8a684b6091f7e20e1137d0ffc5419982ef7f2 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Sun, 4 Oct 2026 23:41:05 +0200 Subject: [PATCH 1/4] Start from a photo, a blank canvas, or the sample photo on the empty state --- src/app/editor/empty.tsx | 166 +++++++++++++++++++++++++++----- src/app/editor/index.tsx | 11 ++- src/app/editor/open.tsx | 13 --- src/app/index.tsx | 6 +- src/components/icons/blank.tsx | 10 ++ src/components/icons/drop.tsx | 9 ++ src/components/icons/sample.tsx | 11 +++ tests/start.e2e.ts | 24 +++++ 8 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 src/components/icons/blank.tsx create mode 100644 src/components/icons/drop.tsx create mode 100644 src/components/icons/sample.tsx create mode 100644 tests/start.e2e.ts diff --git a/src/app/editor/empty.tsx b/src/app/editor/empty.tsx index fbaaec18..b2de6b4a 100644 --- a/src/app/editor/empty.tsx +++ b/src/app/editor/empty.tsx @@ -1,13 +1,20 @@ import { Button } from "@roprgm/ui/button"; +import { Menu, MenuContent, MenuItem, MenuTrigger } from "@roprgm/ui/menu"; import { Notice } from "@roprgm/ui/notice"; import { Section } from "@roprgm/ui/section"; import { Spinner } from "@roprgm/ui/spinner"; +import { type ComponentProps, type ComponentType, useRef } from "react"; import { type Gpu, target } from "vgpu"; import { useGpu } from "vgpu-react"; import type { Workspace } from "@/app/workspace"; import { EditorFrame, EditorLayout } from "@/components/editor/layout"; import { RendererProvider } from "@/components/editor/pipeline"; import { DocumentProvider } from "@/components/editor/session"; +import { BlankIcon } from "@/components/icons/blank"; +import { DropIcon } from "@/components/icons/drop"; +import type { IconProps } from "@/components/icons/icon"; +import { OpenIcon } from "@/components/icons/open"; +import { SampleIcon } from "@/components/icons/sample"; import { createDocument, createResources } from "@/core/document"; import { createImageSource } from "@/core/image"; import { imageFrame } from "@/core/image/frame"; @@ -18,13 +25,16 @@ import { Backdrop } from "./backdrop"; import { DockTabList } from "./dock"; import { EditorHeader } from "./header"; import { createImageLayer } from "./layers"; -import { OpenImage } from "./open"; +import { FileInput } from "./open"; import { createEditorRenderer } from "./renderer"; import { PlaceholderSidebar } from "./sidebar"; import { ToolTabList } from "./tool-rail"; import { tools } from "./tools"; -type OpenProps = { onOpen: (files: File[]) => void }; +type StartProps = { + onOpen: (files: File[]) => void; + onOpenSample: () => void; +}; type EmptyState = Exclude< ReturnType, @@ -54,33 +64,141 @@ function RecoverDraft({ onRecover, onForget }: Recovery) { ); } -function Status({ state, onOpen }: { state: EmptyState } & OpenProps) { - if (state.status === "loading") { - return ; - } - if (state.status === "error") { - return ( - <> -

- Couldn't open {state.file}: {state.error} -

- - - ); +const blankSizes = [ + { name: "Square", width: 2048, height: 2048 }, + { name: "Landscape", width: 3000, height: 2000 }, + { name: "Portrait", width: 2000, height: 3000 }, + { name: "Widescreen", width: 3840, height: 2160 }, +]; + +/** A white PNG, so a blank canvas opens, saves, and recovers as any image file does. */ +async function blankImage(width: number, height: number) { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("A 2D canvas is unavailable."); } + context.fillStyle = "white"; + context.fillRect(0, 0, width, height); + const blob = await canvas.convertToBlob({ type: "image/png" }); + return new File([blob], "Untitled.png", { type: blob.type }); +} + +/** A flat tile over the backdrop; its icon takes the color of the backdrop's motes on hover. */ +function StartTile({ + icon: Glyph, + title, + hint, + ...props +}: ComponentProps<"button"> & { + icon: ComponentType; + title: string; + hint: string; +}) { + return ( + + ); +} + +function BlankCanvas({ onOpen }: Pick) { + return ( + + + } + /> + + {blankSizes.map(({ name, width, height }) => ( + onOpen([await blankImage(width, height)])} + > + {name} + + {width} × {height} + + + ))} + + + ); +} + +/** The ways to begin, with dropping a file onto the page named under them. */ +function Starts({ onOpen, onOpenSample }: StartProps) { + const input = useRef(null); return ( <> +
+ input.current?.click()} + /> + + +
+

+ + Or drop an image anywhere +

+ + + ); +} + +function Welcome() { + return ( +

OpenLight

Edit photos in your browser.

- - +
+ ); +} + +function Status({ state, ...starts }: { state: EmptyState } & StartProps) { + if (state.status === "loading") { + return ; + } + return ( +
+ {state.status === "error" ? ( +

+ Couldn't open {state.file}: {state.error} +

+ ) : ( + + )} + +
); } @@ -101,9 +219,9 @@ function createEmptyDocument(gpu: Gpu) { /** The editor shell without a document: the light backdrop and workspace status fill the canvas area, beside inert controls. */ export function EmptyEditor({ state, - onOpen, draft, -}: { state: EmptyState; draft?: Recovery } & OpenProps) { + ...starts +}: { state: EmptyState; draft?: Recovery } & StartProps) { const gpu = useGpu(); const document = useDisposable(() => createEmptyDocument(gpu), [gpu]); return ( @@ -118,9 +236,9 @@ export function EmptyEditor({ +
- + {draft && }
} diff --git a/src/app/editor/index.tsx b/src/app/editor/index.tsx index 15d6e229..5c5bb1c1 100644 --- a/src/app/editor/index.tsx +++ b/src/app/editor/index.tsx @@ -232,17 +232,26 @@ function DocumentEditor({ type EditorProps = { state: ReturnType; onOpen: (files: File[]) => void; + onOpenSample: () => void; onDismissFailure: () => void; draft?: Recovery; }; function EditorContent({ state, onOpen, + onOpenSample, onDismissFailure, draft, }: EditorProps) { if (state.status !== "ready") { - return ; + return ( + + ); } return ( diff --git a/src/app/editor/open.tsx b/src/app/editor/open.tsx index 95132c4a..72e79eda 100644 --- a/src/app/editor/open.tsx +++ b/src/app/editor/open.tsx @@ -6,7 +6,6 @@ import { createContext, type RefObject, useContext, useRef } from "react"; import { sceneExtension } from "@/app/loaders/scene"; import type { OpenFailure } from "@/app/workspace"; import { OpenIcon } from "@/components/icons/open"; -import { TextLink } from "@/components/ui/text-link"; import { accept } from "@/core/image/decode"; import { useShortcuts } from "@/hooks/use-shortcuts"; @@ -47,18 +46,6 @@ export function FileInput({ ); } -/** The drop hint doubles as the picker: "choose a file" opens the input. */ -export function OpenImage({ onOpen }: OpenProps) { - const input = useRef(null); - return ( -

- Drop an image here or{" "} - input.current?.click()}>choose a file - -

- ); -} - /** Opens another image or scene from the header, in place of the current document once it loads. */ export function OpenButton({ onOpen }: OpenProps) { const input = useRef(null); diff --git a/src/app/index.tsx b/src/app/index.tsx index 31f5f43b..75e9ffbf 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -5,8 +5,9 @@ import { Editor } from "@/app/editor"; import { useWorkspace } from "@/app/workspace/use-workspace"; import { useFileDrop } from "@/hooks/use-file-drop"; -/** Visiting /demo opens a bundled photo instead of the empty state. */ -const startup = location.pathname === "/demo" ? "/images/demo.jpg" : undefined; +/** A bundled photo that the empty state offers and that visiting /demo opens. */ +const samplePhoto = "/images/demo.jpg"; +const startup = location.pathname === "/demo" ? samplePhoto : undefined; /** The experimental assistant mounts only when the URL asks for it, as in `/?assistant`. */ const assistant = new URLSearchParams(location.search).has("assistant"); @@ -24,6 +25,7 @@ export function App() { void controls.loadUrl(samplePhoto)} onDismissFailure={workspace.dismissFailure} draft={recovery} /> diff --git a/src/components/icons/blank.tsx b/src/components/icons/blank.tsx new file mode 100644 index 00000000..26d2a53f --- /dev/null +++ b/src/components/icons/blank.tsx @@ -0,0 +1,10 @@ +import { Icon, type IconProps } from "./icon"; + +export function BlankIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/drop.tsx b/src/components/icons/drop.tsx new file mode 100644 index 00000000..e4445383 --- /dev/null +++ b/src/components/icons/drop.tsx @@ -0,0 +1,9 @@ +import { Icon, type IconProps } from "./icon"; + +export function DropIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/src/components/icons/sample.tsx b/src/components/icons/sample.tsx new file mode 100644 index 00000000..2c445832 --- /dev/null +++ b/src/components/icons/sample.tsx @@ -0,0 +1,11 @@ +import { Icon, type IconProps } from "./icon"; + +export function SampleIcon(props: IconProps) { + return ( + + + + + + ); +} diff --git a/tests/start.e2e.ts b/tests/start.e2e.ts new file mode 100644 index 00000000..d3ba1515 --- /dev/null +++ b/tests/start.e2e.ts @@ -0,0 +1,24 @@ +import { expect, test } from "./fixtures"; +import { readImage } from "./images"; + +test("start from a blank canvas or the sample photo", async ({ page }) => { + const opened = () => + page.evaluate(() => { + const { documentId, file, size } = window.openlight.getState(); + return documentId && { file, size }; + }); + await page.goto("/"); + await page.getByRole("button", { name: /Blank canvas/ }).click(); + await page.getByRole("menuitem", { name: /Square/ }).click(); + await expect + .poll(opened) + .toEqual({ file: "Untitled.png", size: [2048, 2048] }); + expect(await readImage(page)).toMatchObject({ + size: [2048, 2048], + center: [255, 255, 255, 255], + }); + + await page.goto("/"); + await page.getByRole("button", { name: /Sample photo/ }).click(); + await expect.poll(opened).toEqual({ file: "demo.jpg", size: [1400, 764] }); +}); From 789e8d3a98162d1871a75541f1b82ce17c0186aa Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Sun, 4 Oct 2026 23:46:23 +0200 Subject: [PATCH 2/4] Open a blank canvas as a white SVG made at once, so a later start wins --- src/app/editor/empty.tsx | 17 +++++------------ tests/start.e2e.ts | 4 +--- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/app/editor/empty.tsx b/src/app/editor/empty.tsx index b2de6b4a..c99da486 100644 --- a/src/app/editor/empty.tsx +++ b/src/app/editor/empty.tsx @@ -71,17 +71,10 @@ const blankSizes = [ { name: "Widescreen", width: 3840, height: 2160 }, ]; -/** A white PNG, so a blank canvas opens, saves, and recovers as any image file does. */ -async function blankImage(width: number, height: number) { - const canvas = new OffscreenCanvas(width, height); - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("A 2D canvas is unavailable."); - } - context.fillStyle = "white"; - context.fillRect(0, 0, width, height); - const blob = await canvas.convertToBlob({ type: "image/png" }); - return new File([blob], "Untitled.png", { type: blob.type }); +/** A white SVG of that size, so a blank canvas opens, saves, and recovers as any image file does. */ +function blankImage(width: number, height: number) { + const svg = ``; + return new File([svg], "Untitled", { type: "image/svg+xml" }); } /** A flat tile over the backdrop; its icon takes the color of the backdrop's motes on hover. */ @@ -126,7 +119,7 @@ function BlankCanvas({ onOpen }: Pick) { {blankSizes.map(({ name, width, height }) => ( onOpen([await blankImage(width, height)])} + onClick={() => onOpen([blankImage(width, height)])} > {name} diff --git a/tests/start.e2e.ts b/tests/start.e2e.ts index d3ba1515..b7a05bc2 100644 --- a/tests/start.e2e.ts +++ b/tests/start.e2e.ts @@ -10,9 +10,7 @@ test("start from a blank canvas or the sample photo", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: /Blank canvas/ }).click(); await page.getByRole("menuitem", { name: /Square/ }).click(); - await expect - .poll(opened) - .toEqual({ file: "Untitled.png", size: [2048, 2048] }); + await expect.poll(opened).toEqual({ file: "Untitled", size: [2048, 2048] }); expect(await readImage(page)).toMatchObject({ size: [2048, 2048], center: [255, 255, 255, 255], From b8f1c8c8db8812a1e31f5385b182b2bdadfc0371 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 5 Oct 2026 00:30:50 +0200 Subject: [PATCH 3/4] Turn Blank canvas into four size tiles that the start tiles morph into --- src/app/editor/empty.tsx | 217 ++++++++++++++++++++++++++++----------- src/index.css | 35 +++++++ tests/start.e2e.ts | 6 +- 3 files changed, 193 insertions(+), 65 deletions(-) diff --git a/src/app/editor/empty.tsx b/src/app/editor/empty.tsx index c99da486..9586ca78 100644 --- a/src/app/editor/empty.tsx +++ b/src/app/editor/empty.tsx @@ -1,9 +1,15 @@ import { Button } from "@roprgm/ui/button"; -import { Menu, MenuContent, MenuItem, MenuTrigger } from "@roprgm/ui/menu"; import { Notice } from "@roprgm/ui/notice"; import { Section } from "@roprgm/ui/section"; import { Spinner } from "@roprgm/ui/spinner"; -import { type ComponentProps, type ComponentType, useRef } from "react"; +import { + type ComponentProps, + type ReactNode, + type RefObject, + useRef, + useState, +} from "react"; +import { flushSync } from "react-dom"; import { type Gpu, target } from "vgpu"; import { useGpu } from "vgpu-react"; import type { Workspace } from "@/app/workspace"; @@ -12,7 +18,7 @@ import { RendererProvider } from "@/components/editor/pipeline"; import { DocumentProvider } from "@/components/editor/session"; import { BlankIcon } from "@/components/icons/blank"; import { DropIcon } from "@/components/icons/drop"; -import type { IconProps } from "@/components/icons/icon"; +import { Icon } from "@/components/icons/icon"; import { OpenIcon } from "@/components/icons/open"; import { SampleIcon } from "@/components/icons/sample"; import { createDocument, createResources } from "@/core/document"; @@ -20,6 +26,7 @@ import { createImageSource } from "@/core/image"; import { imageFrame } from "@/core/image/frame"; import { MaskToolProvider } from "@/features/layers/mask-tool"; import { useDisposable } from "@/hooks/use-disposable"; +import { useShortcuts } from "@/hooks/use-shortcuts"; import { AdjustDock } from "./adjust"; import { Backdrop } from "./backdrop"; import { DockTabList } from "./dock"; @@ -64,10 +71,11 @@ function RecoverDraft({ onRecover, onForget }: Recovery) { ); } -const blankSizes = [ - { name: "Square", width: 2048, height: 2048 }, - { name: "Landscape", width: 3000, height: 2000 }, - { name: "Portrait", width: 2000, height: 3000 }, +/** Blank canvas sizes, narrowest first; Landscape is a 12 MP photo's 4:3. */ +const canvasSizes = [ + { name: "Portrait", width: 3000, height: 4000 }, + { name: "Square", width: 3000, height: 3000 }, + { name: "Landscape", width: 4000, height: 3000 }, { name: "Widescreen", width: 3840, height: 2160 }, ]; @@ -77,76 +85,93 @@ function blankImage(width: number, height: number) { return new File([svg], "Untitled", { type: "image/svg+xml" }); } -/** A flat tile over the backdrop; its icon takes the color of the backdrop's motes on hover. */ +/** A canvas's proportions, outlined in the frame the other icons' shapes fill. */ +function ShapeIcon({ width, height }: { width: number; height: number }) { + const scale = 14 / Math.max(width, height); + return ( + + + + ); +} + +/** + * A flat tile over the backdrop, a row in a narrow container and a column in a wide one; its icon + * takes the color of the backdrop's motes on hover. Between views, it morphs into the tile of the + * same name while their contents swap. + */ function StartTile({ - icon: Glyph, + name, + icon, title, hint, ...props }: ComponentProps<"button"> & { - icon: ComponentType; + name: string; + icon: ReactNode; title: string; hint: string; }) { return ( ); } -function BlankCanvas({ onOpen }: Pick) { - return ( - - - } - /> - - {blankSizes.map(({ name, width, height }) => ( - onOpen([blankImage(width, height)])} - > - {name} - - {width} × {height} - - - ))} - - - ); -} - -/** The ways to begin, with dropping a file onto the page named under them. */ -function Starts({ onOpen, onOpenSample }: StartProps) { +function StartTiles({ + blank, + onOpen, + onOpenSample, + onChooseSize, +}: StartProps & { + blank: RefObject; + onChooseSize: () => void; +}) { const input = useRef(null); return ( <> -
+
} title="Open a photo" hint="JPEG, HEIC, TIFF, or RAW" onClick={() => input.current?.click()} /> - } + title="Blank canvas" + hint="Start from a white image" + onClick={onChooseSize} + /> + } title="Sample photo" hint="Try the tools on a demo" onClick={onOpenSample} @@ -161,6 +186,77 @@ function Starts({ onOpen, onOpenSample }: StartProps) { ); } +/** The blank canvas sizes, in the start tiles' places, so each tile morphs into one. */ +function CanvasSizes({ + first, + onOpen, + onBack, +}: Pick & { + first: RefObject; + onBack: () => void; +}) { + useShortcuts({ escape: onBack }); + return ( + <> +
+ {canvasSizes.map(({ name, width, height }, index) => ( + } + title={name} + hint={`${width} × ${height}`} + onClick={() => onOpen([blankImage(width, height)])} + /> + ))} +
+ + + ); +} + +/** The ways to begin under a heading; Blank canvas turns the tiles into its sizes and back. */ +function Starts({ + heading, + ...starts +}: StartProps & { + heading: ReactNode; +}) { + const [choosing, setChoosing] = useState(false); + const blank = useRef(null); + const first = useRef(null); + + /** Swaps the tiles inside a view transition, then focuses what shows. */ + function choose(next: boolean) { + window.document.startViewTransition(() => { + flushSync(() => setChoosing(next)); + (next ? first : blank).current?.focus(); + }); + } + + return ( +
+
{heading}
+ {choosing ? ( + choose(false)} + /> + ) : ( + choose(true)} + {...starts} + /> + )} +
+ ); +} + function Welcome() { return (
@@ -181,18 +277,15 @@ function Status({ state, ...starts }: { state: EmptyState } & StartProps) { if (state.status === "loading") { return ; } - return ( -
- {state.status === "error" ? ( -

- Couldn't open {state.file}: {state.error} -

- ) : ( - - )} - -
- ); + const heading = + state.status === "error" ? ( +

+ Couldn't open {state.file}: {state.error} +

+ ) : ( + + ); + return ; } /** A scene over a blank pixel, so the real panels mount with defaults; it never enters the workspace. */ diff --git a/src/index.css b/src/index.css index 5a3d04d4..0ef3ce0b 100644 --- a/src/index.css +++ b/src/index.css @@ -34,3 +34,38 @@ color: var(--color-foreground); } } + +/* A start tile's box morphs into the one that takes its place, as the assistant's card moves; + its content leaves before the new content shows, so their text never overlaps. */ +::view-transition-group(.start-tile), +::view-transition-group(.start-content) { + animation-duration: 300ms; + animation-timing-function: cubic-bezier(0.2, 0, 0, 1); +} + +::view-transition-old(.start-content) { + animation-duration: 100ms; +} + +::view-transition-new(.start-content) { + animation-delay: 100ms; + animation-duration: 200ms; +} + +/* A tile with no counterpart shows once the others have moved aside, and leaves before they do. */ +::view-transition-new(.start-tile):only-child { + animation-delay: 150ms; + animation-duration: 150ms; +} + +::view-transition-old(.start-tile):only-child { + animation-duration: 100ms; +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none; + } +} diff --git a/tests/start.e2e.ts b/tests/start.e2e.ts index b7a05bc2..4a149bb8 100644 --- a/tests/start.e2e.ts +++ b/tests/start.e2e.ts @@ -9,10 +9,10 @@ test("start from a blank canvas or the sample photo", async ({ page }) => { }); await page.goto("/"); await page.getByRole("button", { name: /Blank canvas/ }).click(); - await page.getByRole("menuitem", { name: /Square/ }).click(); - await expect.poll(opened).toEqual({ file: "Untitled", size: [2048, 2048] }); + await page.getByRole("button", { name: /Square/ }).click(); + await expect.poll(opened).toEqual({ file: "Untitled", size: [3000, 3000] }); expect(await readImage(page)).toMatchObject({ - size: [2048, 2048], + size: [3000, 3000], center: [255, 255, 255, 255], }); From b1f9fe240ad3985f381f09388aac30d49eebec51 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Mon, 5 Oct 2026 01:34:53 +0200 Subject: [PATCH 4/4] Swap the start tiles without a view transition where the browser has none --- src/app/editor/empty.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/editor/empty.tsx b/src/app/editor/empty.tsx index 9586ca78..b16b84f0 100644 --- a/src/app/editor/empty.tsx +++ b/src/app/editor/empty.tsx @@ -229,12 +229,17 @@ function Starts({ const blank = useRef(null); const first = useRef(null); - /** Swaps the tiles inside a view transition, then focuses what shows. */ + /** Swaps the tiles inside a view transition where the browser has one, then focuses what shows. */ function choose(next: boolean) { - window.document.startViewTransition(() => { + const swap = () => { flushSync(() => setChoosing(next)); (next ? first : blank).current?.focus(); - }); + }; + if ("startViewTransition" in window.document) { + window.document.startViewTransition(swap); + } else { + swap(); + } } return (