diff --git a/.env.example b/.env.example deleted file mode 100644 index 73eca06..0000000 --- a/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# Staged launch flags. Copy to `.env` for local overrides, and set these on the -# host (Vercel/Netlify/etc.) for the real site. Both are baked in at BUILD time, -# so changing them needs a redeploy. -# -# NOTE: this is obscurity, not security. Every tool ships in the JS bundle no -# matter what these say — the flags only control what is listed and reachable. - -# Comma-separated tool slugs that are public right now. Add one per LinkedIn post. -# Valid slugs: dithering, anti-aliasing, low-res-video, character-flow, -# style-flow, future-table, tanstack-showcase -# Unset in a production build = nothing is listed (fails closed on purpose). -# Unset in dev = everything is listed. -VITE_RELEASED="dithering" - -# The secret in the friends link: https://your-site.com/?key= -# Visiting it once unlocks every tool for that browser (persisted in -# localStorage). `?lock=1` clears it again. -VITE_UNLOCK_SECRET="backstage" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d02db9..cc59f97 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,8 +3,7 @@ name: Deploy to GitHub Pages on: push: branches: [main] - # Lets you re-deploy from the Actions tab after changing VITE_RELEASED, - # which is how each staged launch goes out. + # Lets you re-deploy from the Actions tab without pushing a commit. workflow_dispatch: permissions: @@ -35,15 +34,6 @@ jobs: - run: yarn typecheck - run: yarn build - env: - # Which tools are public. Set this as a *repository variable* - # (Settings → Secrets and variables → Actions → Variables), e.g. - # "dithering,anti-aliasing". Unset = nothing is listed. - VITE_RELEASED: ${{ vars.VITE_RELEASED }} - # The friends-link key: /?key=. A repo *variable*, not a - # secret, on purpose — it is compiled into the public JS bundle, so - # storing it as a secret would only be theatre. - VITE_UNLOCK_SECRET: ${{ vars.VITE_UNLOCK_SECRET }} - uses: actions/configure-pages@v5 diff --git a/AGENTS.md b/AGENTS.md index 1146591..ca348ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,29 +142,15 @@ Corollaries the user cares about, and will call out: export const Route = createFileRoute("/")({ component: Thing }) ``` `routeTree.gen.ts` regenerates itself when the dev server runs. -3. **Flag key** — add the slug to `ToolKey` *and* `ALL_TOOLS` in - `src/features/flags/flags.ts`. The slug **is** the route path, minus the - leading slash; that identity is what lets `toolFromPathname` work. -4. **Nav entry** — add to the right group in `src/features/sidebar/nav-items.ts`: - `title`, `to`, `tool`, `icon` (lucide), `blurb` (the home-card teaser), and +3. **Nav entry** — add to the right group in `src/features/sidebar/nav-items.ts`: + `title`, `to`, `icon` (lucide), `blurb` (the home-card teaser), and `keywords` (hidden search aliases — jargon and synonyms a person might type - that don't appear in the title). `tool` is mandatory by design: a tool cannot - join the nav without someone deciding when it goes public. + that don't appear in the title). +4. **Page meta** — give the route a `head()` via `pageMeta` from + `src/features/seo/meta.ts`, and add the path to `PRERENDERED_PATHS` in + `vite.config.ts`. Without both, the page has no unfurl and deep links 404. 5. **`how-it-works.md`** — in the feature folder. Non-optional; see below. -Then also mention the new slug in `.env.example`'s valid-slugs comment. - -## Release flags - -`src/features/flags/flags.ts` runs a staged launch. `VITE_RELEASED` is a -comma-separated slug list of what's public; unset in dev means *everything* is -visible, unset in a production build means *nothing* is (fails closed on -purpose). `NEVER_RELEASED` pins tools to unlock-only forever. `/?key=` -unlocks everything for a browser and persists it; `/?lock=1` clears it. - -This is obscurity, not security — every tool ships in the JS bundle regardless. -It only controls what is listed and reachable. - ## how-it-works.md — the format Every tool has one, and it is a deliverable, not an afterthought: the point of diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 133d518..7454b4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,10 +39,6 @@ yarn install yarn dev # http://localhost:3005 ``` -The sidebar only lists *released* tools. To see everything locally, add slugs -to `VITE_RELEASED` in a local `.env` (copy `.env.example`), or load -`/?key=`. - ## Before you open a PR Run the same four checks CI runs: diff --git a/README.md b/README.md index 3679b79..208c92a 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,6 @@ yarn install yarn dev # http://localhost:3005 ``` -The sidebar lists only released tools. Copy `.env.example` to `.env` to see the -rest locally. - ## Contributing Bug fixes and typo fixes are very welcome; new tools start as an issue. diff --git a/public/dither-default.jpg b/public/dither-default.jpg new file mode 100644 index 0000000..4637354 Binary files /dev/null and b/public/dither-default.jpg differ diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..b6b8659 Binary files /dev/null and b/public/og.png differ diff --git a/scripts/og-card.svg b/scripts/og-card.svg new file mode 100644 index 0000000..ec34449 --- /dev/null +++ b/scripts/og-card.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + webfun + Small interactive experiments in + graphics, motion and speculative UI. + + FANOFLIX.GITHUB.IO/WEBFUN + + \ No newline at end of file diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 0bab6c1..581afed 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -7,7 +7,6 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar" -import { ToolGate } from "@/features/flags/ToolGate" import { AppSidebar } from "@/features/sidebar/AppSidebar" import { WebfunMark } from "@/features/sidebar/WebfunMark" @@ -47,9 +46,7 @@ export function AppLayout() {
- - - +
diff --git a/src/components/upload/FileDropZone.tsx b/src/components/upload/FileDropZone.tsx index dc94d6b..fafbdcf 100644 --- a/src/components/upload/FileDropZone.tsx +++ b/src/components/upload/FileDropZone.tsx @@ -3,6 +3,21 @@ import type { DragEvent, ReactNode } from "react" import { cn } from "@/lib/utils" +/** What a browser reports when it has no idea what the file is. */ +const GENERIC_TYPE = "application/octet-stream" + +/** + * Fallback matching for files the browser hasn't identified. + * + * Typed as possibly-undefined because `accept` is a template-literal type: a + * caller can legitimately pass `audio/*` or `font/*`, which has no entry here + * and should simply fall through to "not accepted" rather than crash. + */ +const EXTENSIONS: Record = { + image: /\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i, + video: /\.(mp4|webm|mov|m4v|ogv|avi|mkv)$/i, +} + type Props = { /** An `accept` value with a wildcard subtype, e.g. `image/*`. */ accept: `${string}/*` @@ -40,12 +55,30 @@ export function FileDropZone({ const inputRef = useRef(null) const [dragging, setDragging] = useState(false) - // "image/*" → "image/". Anything else dropped here is ignored rather than - // handed to a decoder that will fail on it. - const prefix = `${accept.slice(0, accept.indexOf("/"))}/` + // "image/*" → "image" + const kind = accept.slice(0, accept.indexOf("/")) + /** + * Accept by MIME type, falling back to the extension when the browser hasn't + * actually identified the file. + * + * A strict `type.startsWith("video/")` check looks right and drops real files + * on the floor. The OS decides the type, and it often declines: `.mov` and + * `.mkv` frequently arrive as `application/octet-stream`, and files dragged + * out of an archive or off some Linux file managers arrive with no type at + * all. Both used to fail silently, which reads as a broken upload with + * nothing on screen to explain it. + * + * Anything with neither a matching type nor a matching extension is still + * ignored, rather than handed to a decoder that will fail on it. + */ const takeFile = (file: File | null | undefined) => { - if (file?.type.startsWith(prefix)) onPick(file) + if (!file) return + const identified = file.type && file.type !== GENERIC_TYPE + const ok = identified + ? file.type.startsWith(`${kind}/`) + : EXTENSIONS[kind]?.test(file.name) + if (ok) onPick(file) } const onDrop = (e: DragEvent) => { diff --git a/src/features/canvas-stuff/anti-aliasing/raster.test.ts b/src/features/canvas-stuff/anti-aliasing/raster.test.ts index b21346f..f36eebe 100644 --- a/src/features/canvas-stuff/anti-aliasing/raster.test.ts +++ b/src/features/canvas-stuff/anti-aliasing/raster.test.ts @@ -13,32 +13,30 @@ const BASE = { } describe("findEdge", () => { - it("finds an edge for every scene, so the loupe is never parked on flat colour", () => { + it("finds an edge for every scene, so the loupe is never parked on flat fill", () => { for (const { value } of SCENES) { - const data = render(W, H, { ...BASE, scene: value }) - const edge = findEdge(data, W, H) + const { coverage } = render(W, H, { ...BASE, scene: value }) + const edge = findEdge(coverage, W, H) expect(edge, `scene ${value}`).not.toBeNull() const px = Math.floor(edge!.x * W) const py = Math.floor(edge!.y * H) - const r = data[(py * W + px) * 4] - // Neither pure background nor pure foreground: a partially covered pixel. - expect(r, `scene ${value}`).toBeGreaterThan(18) - expect(r, `scene ${value}`).toBeLessThan(236) + const a = coverage[py * W + px] + expect(a, `scene ${value}`).toBeGreaterThan(0) + expect(a, `scene ${value}`).toBeLessThan(1) } }) it("returns null when nothing is partially covered", () => { // One sample per pixel is a yes/no answer, so no pixel is ever partial. - const aliased = render(W, H, { ...BASE, samples: 1 }) - expect(findEdge(aliased, W, H)).toBeNull() + const { coverage } = render(W, H, { ...BASE, samples: 1 }) + expect(findEdge(coverage, W, H)).toBeNull() }) it("prefers the edge nearest the middle of the frame", () => { - const data = render(W, H, BASE) - const edge = findEdge(data, W, H)! + const { coverage } = render(W, H, BASE) + const edge = findEdge(coverage, W, H)! - // Every other partial pixel must be at least as far from the centre. const cx = W / 2 const cy = H / 2 const dist = (x: number, y: number) => (x - cx) ** 2 + (y - cy) ** 2 @@ -46,10 +44,56 @@ describe("findEdge", () => { for (let py = 0; py < H; py++) { for (let px = 0; px < W; px++) { - const r = data[(py * W + px) * 4] - if (r <= 18 || r >= 236) continue + const a = coverage[py * W + px] + if (a <= 0 || a >= 1) continue expect(dist(px + 0.5, py + 0.5)).toBeGreaterThanOrEqual(chosen - 1e-9) } } }) + + it("is not fooled by a shaded interior", () => { + // The sphere's inside is a gradient, so plenty of its pixels are mid-grey + // without being edge pixels. Reading coverage rather than colour is what + // keeps this honest. + const { data, coverage } = render(W, H, { ...BASE, scene: "sphere" }) + const edge = findEdge(coverage, W, H)! + const px = Math.floor(edge.x * W) + const py = Math.floor(edge.y * H) + + expect(coverage[py * W + px]).toBeGreaterThan(0) + expect(coverage[py * W + px]).toBeLessThan(1) + + // Prove the trap exists: some fully-covered pixel is mid-grey too. + let litMidTone = false + for (let i = 0; i < coverage.length; i++) { + const r = data[i * 4] + if (coverage[i] === 1 && r > 40 && r < 200) litMidTone = true + } + expect(litMidTone).toBe(true) + }) +}) + +describe("render", () => { + it("anti-aliases the silhouette of a shaded sphere", () => { + const aliased = render(W, H, { ...BASE, scene: "sphere", samples: 1 }) + const smooth = render(W, H, { ...BASE, scene: "sphere", samples: 4 }) + + const partial = (c: Float32Array) => + [...c].filter((a) => a > 0 && a < 1).length + + // Samples is still doing the whole job on the edge; shading never creates + // partial coverage. + expect(partial(aliased.coverage)).toBe(0) + expect(partial(smooth.coverage)).toBeGreaterThan(0) + }) + + it("leaves flat scenes unshaded", () => { + const { data, coverage } = render(W, H, { ...BASE, scene: "circle" }) + // Every fully covered pixel of a flat scene is the same foreground colour. + const insides = new Set() + for (let i = 0; i < coverage.length; i++) { + if (coverage[i] === 1) insides.add(data[i * 4]) + } + expect(insides.size).toBe(1) + }) }) diff --git a/src/features/canvas-stuff/anti-aliasing/raster.ts b/src/features/canvas-stuff/anti-aliasing/raster.ts index b82f51a..5e9f101 100644 --- a/src/features/canvas-stuff/anti-aliasing/raster.ts +++ b/src/features/canvas-stuff/anti-aliasing/raster.ts @@ -8,6 +8,7 @@ export type Scene = | "line" | "edge" | "circle" + | "sphere" | "triangle" | "pentagon" | "checker" @@ -16,6 +17,7 @@ export const SCENES: { value: Scene; label: string }[] = [ { value: "line", label: "Diagonal line" }, { value: "edge", label: "Slanted edge" }, { value: "circle", label: "Circle" }, + { value: "sphere", label: "Shaded sphere" }, { value: "triangle", label: "Triangle" }, { value: "pentagon", label: "Pentagon" }, { value: "checker", label: "Checkerboard" }, @@ -71,6 +73,10 @@ function inside( case "edge": return v >= 0 case "circle": + case "sphere": + // Same silhouette; the sphere differs only in what it paints *inside*, + // which is deliberate — the anti-aliasing is happening on this edge and + // nowhere else. return u * u + v * v <= size * size case "triangle": return insidePolygon(u, v, 3, size) @@ -83,9 +89,54 @@ function inside( } } -/** Render a scene to a fresh RGBA buffer of length `w * h * 4`. */ -export function render(w: number, h: number, s: AASettings): Uint8ClampedArray { +/** Light direction for the shaded sphere, normalised, pointing from the surface. */ +const LIGHT: readonly [number, number, number] = [-0.53, -0.58, 0.62] + +/** + * How bright the *interior* of a scene is at `(u, v)`, 0..1. + * + * Flat scenes return 1 and cost nothing. The sphere reconstructs a surface + * normal from the silhouette — `nz = sqrt(1 - nx² - ny²)` — and lights it, so + * the interior is a smooth gradient while the edge stays a pure coverage + * problem. + * + * Keeping shading separate from coverage is what makes this legitimate: the + * silhouette is still anti-aliased by supersampling alone, and the Samples + * slider still does the whole job. A shaded pixel is not a partly-covered one. + */ +function shadeAt(scene: Scene, u: number, v: number, size: number): number { + if (scene !== "sphere") return 1 + + const nx = u / size + const ny = v / size + const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny)) + + const lambert = Math.max(0, -(nx * LIGHT[0] + ny * LIGHT[1]) + nz * LIGHT[2]) + const specular = lambert ** 48 + // A little rim light, or the dark limb vanishes into the background and the + // silhouette we came here to look at stops being visible at all. + const rim = 0.16 * (1 - nz) ** 2 + + return Math.min(1, 0.07 + 0.86 * lambert + 0.5 * specular + rim) +} + +/** + * Render a scene. + * + * Returns the RGBA buffer *and* the raw per-pixel coverage. Coverage can no + * longer be read back out of the pixels: with a shaded scene a mid-grey pixel + * might be fully covered but dimly lit, so anything looking for edges (see + * `findEdge`) has to be handed the real thing. + */ +export type Render = { + data: Uint8ClampedArray + /** Per-pixel coverage, 0..1, row-major. */ + coverage: Float32Array +} + +export function render(w: number, h: number, s: AASettings): Render { const out = new Uint8ClampedArray(w * h * 4) + const coverage = new Float32Array(w * h) const half = Math.min(w, h) / 2 // One output pixel, measured in the normalised (u, v) units inside(). const pxNorm = 1 / half @@ -99,49 +150,56 @@ export function render(w: number, h: number, s: AASettings): Uint8ClampedArray { for (let py = 0; py < h; py++) { for (let px = 0; px < w; px++) { let cov = 0 + let shade = 0 for (let sy = 0; sy < n; sy++) { for (let sx = 0; sx < n; sx++) { const u = (px + (sx + 0.5) * inv - w / 2) / half const v = (py + (sy + 0.5) * inv - h / 2) / half const ru = u * cos + v * sin const rv = -u * sin + v * cos - if (inside(s.scene, ru, rv, s.size, pxNorm)) cov++ + if (inside(s.scene, ru, rv, s.size, pxNorm)) { + cov++ + shade += shadeAt(s.scene, ru, rv, s.size) + } } } const a = cov / total + // Averaged over the covered samples only: an edge pixel's colour is its + // own shade blended toward the background by its coverage, not a shade + // diluted twice. + const lit = cov > 0 ? shade / cov : 0 const i = (py * w + px) * 4 - out[i] = BG[0] + (FG[0] - BG[0]) * a - out[i + 1] = BG[1] + (FG[1] - BG[1]) * a - out[i + 2] = BG[2] + (FG[2] - BG[2]) * a + coverage[py * w + px] = a + out[i] = BG[0] + (FG[0] - BG[0]) * lit * a + out[i + 1] = BG[1] + (FG[1] - BG[1]) * lit * a + out[i + 2] = BG[2] + (FG[2] - BG[2]) * lit * a out[i + 3] = 255 } } - return out + return { data: out, coverage } } /** * The edge pixel nearest the middle of the frame, in normalised (0..1) coords. * * The loupe used to open at dead centre, which for every scene here is deep - * *inside* the shape — a flat white square. That made the one instrument - * capable of settling "is this actually anti-aliased?" show nothing at all - * until you dragged it somewhere useful. + * *inside* the shape — a flat fill. That made the one instrument capable of + * settling "is this actually anti-aliased?" show nothing at all until you + * dragged it somewhere useful. * - * An edge is found by coverage, not geometry, so this works for any scene - * without a per-scene table: a pixel that is neither fully background nor - * fully foreground is by definition one the shape's boundary passed through. - * Ties break towards the centre, so the result is deterministic. + * It reads the coverage map rather than the pixels, so it works for any scene + * without a per-scene table *and* stays correct for shaded ones, where a + * mid-grey pixel is usually just a dim part of the interior rather than an + * edge. Ties break towards the centre, so the result is deterministic. * - * Returns `null` for a frame with no partial pixels at all (a 1-sample render, - * or an empty scene), leaving the caller's current centre alone. + * Returns `null` when nothing is partially covered (a 1-sample render, or an + * empty scene), leaving the caller's current centre alone. */ export function findEdge( - data: Uint8ClampedArray, + coverage: Float32Array, w: number, h: number ): { x: number; y: number } | null { - const lo = Math.min(BG[0], FG[0]) - const hi = Math.max(BG[0], FG[0]) const cx = w / 2 const cy = h / 2 @@ -150,8 +208,8 @@ export function findEdge( for (let py = 0; py < h; py++) { for (let px = 0; px < w; px++) { - const r = data[(py * w + px) * 4] - if (r <= lo || r >= hi) continue + const a = coverage[py * w + px] + if (a <= 0 || a >= 1) continue const dx = px + 0.5 - cx const dy = py + 0.5 - cy const dist = dx * dx + dy * dy diff --git a/src/features/canvas-stuff/anti-aliasing/useAntiAlias.ts b/src/features/canvas-stuff/anti-aliasing/useAntiAlias.ts index 29bc2f7..ad2f988 100644 --- a/src/features/canvas-stuff/anti-aliasing/useAntiAlias.ts +++ b/src/features/canvas-stuff/anti-aliasing/useAntiAlias.ts @@ -7,13 +7,19 @@ import { SCENE_ASPECT, findEdge, render } from "./raster" import type { AASettings, Scene } from "./raster" const DEFAULT_SETTINGS: AASettings = { - scene: "pentagon", - samples: 4, - angle: 18, + // The shaded sphere opens the tool: a curved silhouette is where jaggies read + // worst, and the lit interior gives the eye somewhere to be that isn't the + // staircase — so the edge is the thing you notice, not the only thing there. + scene: "sphere", + samples: 5, + angle: 154, size: 0.6, - resolution: 200, + resolution: 260, } +/** Opening magnification of the loupe. */ +const DEFAULT_ZOOM = 6.9 + // Fixed on-screen size; the working buffer is scaled up (pixelated) to fill it, // which is what makes the jaggies visible. const DISPLAY_W = 720 @@ -47,7 +53,7 @@ export function useAntiAlias() { const [collapsed, setCollapsed] = useState(false) const [animating, setAnimating] = useState(false) const [center, setCenter] = useState({ x: 0.5, y: 0.5 }) - const [zoomLevel, setZoomLevel] = useState(8) + const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM) // Bumped after each repaint so the loupe refreshes. const [frameVersion, setFrameVersion] = useState(0) @@ -63,17 +69,17 @@ export function useAntiAlias() { useEffect(() => { const { w, h } = workDims(settings.resolution) const smooth = render(w, h, settings) - canvasRef.current?.paint(smooth, w, h) + canvasRef.current?.paint(smooth.data, w, h) canvasRef.current?.paintCompare( - render(w, h, { ...settings, samples: 1 }), + render(w, h, { ...settings, samples: 1 }).data, w, h ) - // Park the loupe on an actual edge, or it opens on flat white and the + // Park the loupe on an actual edge, or it opens on flat fill and the // before/after it exists to show is invisible until you go looking. if (parkedOn.current !== settings.scene) { - const edge = findEdge(smooth, w, h) + const edge = findEdge(smooth.coverage, w, h) if (edge) setCenter(edge) parkedOn.current = settings.scene } diff --git a/src/features/canvas-stuff/dithering/useDither.ts b/src/features/canvas-stuff/dithering/useDither.ts index 4141cb5..646c5d3 100644 --- a/src/features/canvas-stuff/dithering/useDither.ts +++ b/src/features/canvas-stuff/dithering/useDither.ts @@ -26,6 +26,20 @@ const LOUPE_SIZE = 288 const MIN_ZOOM = 1 const MAX_ZOOM = 32 +/** + * The image the tool opens with. + * + * An empty canvas made the first ten seconds a chore: the whole point is a + * before/after, and there was nothing to compare until you went and found a + * file. It's a synthetic still life on purpose — long smooth ramps are exactly + * where naive quantisation bands worst, so the technique has something to show + * immediately, and nothing here is anyone else's photo. + * + * Through the base path: on Pages the site is served from `/webfun/`, so a bare + * `/dither-default.jpg` would resolve against the domain root. + */ +const DEFAULT_IMAGE = `${import.meta.env.BASE_URL.replace(/\/+$/, "")}/dither-default.jpg` + const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)) @@ -210,10 +224,7 @@ export function useDither() { const setZoom = (value: number) => setZoomLevel(clamp(value, MIN_ZOOM, MAX_ZOOM)) - const pickFile = (file: File) => { - if (urlRef.current) URL.revokeObjectURL(urlRef.current) - const url = URL.createObjectURL(file) - urlRef.current = url + const load = (url: string) => { const img = new Image() img.onload = () => { imgRef.current = img @@ -222,6 +233,21 @@ export function useDither() { img.src = url } + const pickFile = (file: File) => { + // Only object URLs need revoking; the default image is a plain path, and + // calling revoke on it would be a silent no-op that reads like a bug. + if (urlRef.current) URL.revokeObjectURL(urlRef.current) + const url = URL.createObjectURL(file) + urlRef.current = url + load(url) + } + + // Open with something on screen. Runs once; a file picked before it decodes + // still wins, because `load` only commits in its own `onload`. + useEffect(() => { + load(DEFAULT_IMAGE) + }, []) + const exportPng = () => canvasRef.current?.exportPng("dithered.png") // Fit the result inside the display box, preserving the source aspect. diff --git a/src/features/canvas-stuff/low-res-video/Controls.tsx b/src/features/canvas-stuff/low-res-video/Controls.tsx index d180afa..225f201 100644 --- a/src/features/canvas-stuff/low-res-video/Controls.tsx +++ b/src/features/canvas-stuff/low-res-video/Controls.tsx @@ -1,13 +1,5 @@ import { useRef, useState } from "react" -import { - Minus, - Pause, - Play, - Plus, - Upload, - Volume2, - VolumeX, -} from "lucide-react" +import { Minus, Plus, Upload } from "lucide-react" import { Button } from "@/components/ui/button" import type { DotShape } from "./PixelScreen" @@ -25,36 +17,22 @@ type Props = { settings: ScreenSettings onChange: (patch: Partial) => void hasVideo: boolean - playing: boolean - onTogglePlay: () => void - muted: boolean - onToggleMute: () => void lockAspect: boolean onToggleLockAspect: () => void shape: DotShape onShapeChange: (shape: DotShape) => void onPickFile: (file: File) => void - currentTime: number - duration: number - onSeek: (time: number) => void } export function Controls({ settings, onChange, hasVideo, - playing, - onTogglePlay, - muted, - onToggleMute, lockAspect, onToggleLockAspect, shape, onShapeChange, onPickFile, - currentTime, - duration, - onSeek, }: Props) { const fileRef = useRef(null) const [collapsed, setCollapsed] = useState(false) @@ -93,24 +71,6 @@ export function Controls({ {hasVideo ? "Replace video" : "Upload video"} - - - {hasVideo && ( -
- - {formatTime(currentTime)} - - onSeek(e.target.valueAsNumber)} - aria-label="Seek" - className="h-1 flex-1 cursor-pointer accent-primary" - /> - - {formatTime(duration)} - -
- )} - + {/* The transport rides *inside* the screen container rather than sitting + under it: the screen is resizable, and a sibling bar either forced a + fixed width on the whole thing or overflowed once the screen grew past + it. As an overlay it simply tracks whatever size the screen is. */} + + {hasVideo && ( + + )}