Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions .env.example

This file was deleted.

12 changes: 1 addition & 11 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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=<secret>. 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

Expand Down
26 changes: 6 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,29 +142,15 @@ Corollaries the user cares about, and will call out:
export const Route = createFileRoute("/<slug>")({ 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=<secret>`
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
Expand Down
4 changes: 0 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<VITE_UNLOCK_SECRET>`.

## Before you open a PR

Run the same four checks CI runs:
Expand Down
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file added public/dither-default.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions scripts/og-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 1 addition & 4 deletions src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -47,9 +46,7 @@ export function AppLayout() {
<InsetSidebarTrigger />
</header>
<main className="flex flex-1 items-center justify-center p-6">
<ToolGate>
<Outlet />
</ToolGate>
<Outlet />
</main>
</SidebarInset>
</SidebarProvider>
Expand Down
41 changes: 37 additions & 4 deletions src/components/upload/FileDropZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RegExp | undefined> = {
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}/*`
Expand Down Expand Up @@ -40,12 +55,30 @@ export function FileDropZone({
const inputRef = useRef<HTMLInputElement>(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) => {
Expand Down
72 changes: 58 additions & 14 deletions src/features/canvas-stuff/anti-aliasing/raster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,43 +13,87 @@ 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
const chosen = dist(edge.x * W, edge.y * H)

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<number>()
for (let i = 0; i < coverage.length; i++) {
if (coverage[i] === 1) insides.add(data[i * 4])
}
expect(insides.size).toBe(1)
})
})
Loading
Loading