From b2ba1fae5b6fc10f50682c317c2e7a796b3dc911 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 21:43:59 +0700 Subject: [PATCH 01/11] docs(spec): open-sourcing GoodWebTools design Co-Authored-By: Claude Opus 4.8 --- .../2026-07-26-open-source-repo-design.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-open-source-repo-design.md diff --git a/docs/superpowers/specs/2026-07-26-open-source-repo-design.md b/docs/superpowers/specs/2026-07-26-open-source-repo-design.md new file mode 100644 index 0000000..024a2cd --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-open-source-repo-design.md @@ -0,0 +1,141 @@ +# Open-Sourcing GoodWebTools — Design + +**Status:** Approved (2026-07-26) +**Goal:** Take the currently-private `slaveofcode/goodwebtools` repo public with a clean history, genericized (self-hostable) infrastructure, full community-contribution scaffolding, and PR CI — without disrupting the live Cloudflare deploy. + +## Decisions (locked) + +| Decision | Choice | +|---|---| +| Repo strategy | **Make this repo public in-place** + purge `main` history to a single commit | +| Owner infra | **Genericize + self-hostable** (externalize owner values, keep deploy working) | +| Internal planning docs | **Move to a `design-history` branch** (off `main`) | +| License | **MIT** | +| Contributor CI | **test + build + lint** on PRs | +| Discussions | **Enabled** | +| Desktop `release.yml`/updater | **Kept + documented** as owner-specific (not removed) | + +## Constraints / invariants + +- **The live site must never break.** Production (`main`) and staging (`develop`) deploy via Cloudflare Workers Builds on push; the repo must remain deployable at every step. +- **Nothing sensitive is ever exposed publicly.** History purge + infra cleanup complete *before* the repo flips to public. (History is already secret-free — verified: no `.env`, keys, or tokens ever committed — so the purge is for clean presentation, not remediation.) +- **Genericize, don't break.** Owner-specific values that aren't secrets (bucket names, domain, updater pubkey, worker names) stay concrete but documented as "change for your own deploy." Only the GA measurement ID moves to an env var (so forks don't report to the owner's Analytics property). +- Full pre-purge history is preserved in a **private** `git bundle` (recoverable), never published. + +--- + +## Component A: History, branches & backup + +### A1. Private safety backup (before anything destructive) +`git bundle create ../goodwebtools-full-history-.bundle --all` → a single-file, complete copy of all 426 commits + refs, stored outside the repo (private). Recoverable via `git clone `. Optionally also push all refs to a private backup remote. This is insurance only; not published. + +### A2. `design-history` branch (public, clean) +A single **orphan** commit containing only the internal planning artifacts: +- `docs/superpowers/**` (specs + plans) +- `plan.md` (root) +- A short `README.md` on that branch explaining it holds design history. + +Created via `git checkout --orphan design-history` → keep only those paths → one commit → push. This preserves the planning docs (linkable at `tree/design-history`) without publishing the messy 426-commit history or AI co-author trailers. + +### A3. Squash `main` to a single commit +After all cleanup (B/C/D) is merged to `main` and the deploy is verified: +- `git checkout --orphan public-main` at `main`'s tree → single commit `chore: initial public release`, authored by the owner, **no `Co-Authored-By` trailers** → replace `main` → force-push. +- `develop` is reset to match `main` (single commit) so the two branches share the clean base going forward. + +### A4. Tag `desktop-v1.0.0-beta.1` +Deleted locally and on origin (`git push origin :refs/tags/desktop-v1.0.0-beta.1`), and its draft GitHub Release removed. It points at soon-to-be-purged commits and never published assets (billing-blocked). Desktop can be re-tagged fresh from the new history later. + +**Interface — Produces:** a clean public `main` (1 commit), a public `design-history` branch, a private history bundle. **Consumes:** the fully-cleaned tree from B/C/D. + +--- + +## Component B: Infra genericization + +Each change keeps the owner's deploy working while making the repo fork-friendly. + +### B1. `astro.config.mjs` — GA ID → env +Replace the hardcoded `const PROD_GA_ID = 'G-4Q9F8CL7FW'` with `const PROD_GA_ID = process.env.SITE_GA_ID || ''`. The existing branch-gating stays: on Workers Builds `main` builds, GA loads **only if** `SITE_GA_ID` is set; other branches and forks get none. +- **Owner action (documented):** set `SITE_GA_ID=G-4Q9F8CL7FW` as a **production build variable** in the production Worker's Workers Builds config. (One small dashboard var; GA IDs are already public in page-source, so this isn't about secrecy — it's so forks don't inherit the owner's property.) + +### B2. `wrangler.jsonc` — document, keep concrete +Worker names (`goodwebtools`, `goodwebtools-staging`) and R2 bucket names stay concrete (Workers Builds reads them directly). Add a top-of-file comment block: "These names are specific to this deployment — rename them (and create your own R2 buckets) to self-host." No functional change. + +### B3. Domain / branding — document +`src/config.ts` (`SITE_URL`, `REPO_URL`), `public/robots.txt`, `astro.config.mjs` `site` keep `goodwebtools.com` / `slaveofcode`. Add a note in the self-hosting docs that these are the canonical owner values to change for a fork. (No env indirection — keeps SSG canonical/OG/sitemap simple; forks edit `config.ts`.) + +### B4. Desktop (`tauri.conf.json`, `release.yml`, `RELEASING-DESKTOP.md`) — keep + document +Updater `pubkey` (public-safe), `endpoints` (owner's releases), and the signing workflow (uses `secrets.*` a fork supplies) are kept. `RELEASING-DESKTOP.md` gains a "these are owner-specific; a fork sets its own signing key + endpoints" note. The private signing key is **not** in the repo (already only referenced via `secrets.*`). + +### B5. Scripts / `.npmrc` — unchanged +`stage-models.mjs`, `sync-r2.mjs`, `copy-wasm.mjs`, `download-ffmpeg-binaries.mjs`, `.npmrc` (legacy-peer-deps) are all needed to build/run and are already generic (`sync-r2` reads bucket names from `wrangler.jsonc`). No change. + +### B6. Deploy docs → self-hosting guide +`DEPLOYMENT.md` + `DEPLOYMENT-GIT.md` reframed with a **"Deploy your own instance"** section: create your Cloudflare account + Worker + R2 buckets, set `SITE_GA_ID` (optional), `stage:models` + `sync:r2`, connect Workers Builds. Owner-specific identifiers called out as "replace with yours." + +--- + +## Component C: Community-health files + README + +Created at repo root / `.github/`: +- **`LICENSE`** — MIT, `Copyright (c) 2026 `. +- **`CONTRIBUTING.md`** — prerequisites, `npm i --legacy-peer-deps`, `npm run dev`, `npm test`, lint/format, branch/PR flow (`develop` base), and a **"Add a new tool"** walkthrough of the registry pattern (`src/registry/tools.ts` `ToolDef` + island + `*.lib.ts` + tests) — the primary contributor on-ramp. +- **`CODE_OF_CONDUCT.md`** — Contributor Covenant v2.1, owner contact. +- **`SECURITY.md`** — private vulnerability disclosure (contact + scope: client-side/privacy focus). +- **`.github/ISSUE_TEMPLATE/`** — `bug_report.yml`, `feature_request.yml` (tool suggestions), `config.yml` (link to Discussions). +- **`.github/PULL_REQUEST_TEMPLATE.md`** — checklist (tests pass, lint clean, tool registered, no server assets touched). +- **`README.md` (rewritten, public-facing)** — one-liner + live URL, "privacy-first, runs in your browser" pitch, categorized tool list, tech stack (Astro + React islands, Tauri desktop), quickstart, **Add-a-tool** + **Self-hosting** + **Contributing** pointers, MIT badge, screenshots placeholder. + +--- + +## Component D: Contributor CI + +`.github/workflows/ci.yml`: +- **Triggers:** `pull_request` + `push` to `develop`/`main`. +- **Job (ubuntu, Node 20):** `npm ci` → `npm test -- --run` → `npm run build` → `npm run lint`. +- Build heap is already handled by the `build` script (`cross-env NODE_OPTIONS=--max-old-space-size=8192`). `.npmrc` handles peer deps for `npm ci`. +- No secrets required (pure verification), so it runs safely on fork PRs. + +This is separate from `release.yml` (desktop) and does not deploy. + +--- + +## Component E: Execution sequence (safety-ordered) + +1. **Backup** — create the private history bundle (A1). +2. **`design-history` branch** — orphan-commit the internal docs, push (A2). +3. **Cleanup on `develop`** — apply B (genericize), C (community files + README), D (CI); remove `docs/superpowers/**` + `plan.md` from the working tree. Normal commits. +4. **Verify staging** — push `develop`; confirm Cloudflare staging build succeeds, `npm test`/`build`/`lint` green. +5. **Promote** — PR `develop`→`main`, merge; confirm **production** still deploys and GA loads (with `SITE_GA_ID` set in Cloudflare). +6. **Purge history** — squash `main` to one commit + force-push (A3); reset `develop` to match; delete the desktop tag/release (A4). +7. **Go public** — `gh repo edit --visibility public`. +8. **Repo settings** — Component F. + +Steps 1–5 are reversible; the irreversible steps (6–7) happen only after the deploy is proven green on the cleaned tree. + +--- + +## Component F: Repo settings & exposure + +Via `gh` / dashboard, after going public: +- **Description** + **topics** (`astro`, `react`, `privacy`, `web-tools`, `client-side`, `tauri`, `pdf`, `image-tools`, `developer-tools`, `offline`). +- **Enable Issues + Discussions.** +- **Branch protection on `main`:** require the CI workflow to pass + 1 approving review; no direct pushes. +- Social-preview image (noted as a follow-up asset for the owner to upload). +- Launch checklist: verify links, live URL, CI badge renders, first "good first issue" labels. + +--- + +## Success criteria + +- Repo is public; `main` is a single clean commit; `design-history` holds the planning docs; full history recoverable from the private bundle. +- No owner GA ID or other owner-only value forces itself on forks; the owner's production site still builds, deploys, and reports to GA. +- A new contributor can: clone → `npm i --legacy-peer-deps` → `npm run dev`, read CONTRIBUTING, add a tool, open a PR, and see CI run. +- `npm test` (429), `npm run build`, `npm run lint` all green on `main`; CI passes on a test PR. +- Issues/Discussions open; `main` protected behind CI + review. + +## Out of scope (YAGNI) + +- Migrating deploys to a different provider or a second repo. +- A new desktop release (billing-blocked; separate effort). +- Marketing/launch posts, website redesign, logo/social-preview art (owner follow-up). +- Rewriting tool code for style; only infra/docs/scaffolding change here. From 40f6d6f2d2f9cf17a053703d5b53e9755a341223 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 21:52:34 +0700 Subject: [PATCH 02/11] docs(spec): add desktop release (post-public) + signing-key gate Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-26-open-source-repo-design.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-07-26-open-source-repo-design.md b/docs/superpowers/specs/2026-07-26-open-source-repo-design.md index 024a2cd..3ed8e06 100644 --- a/docs/superpowers/specs/2026-07-26-open-source-repo-design.md +++ b/docs/superpowers/specs/2026-07-26-open-source-repo-design.md @@ -29,6 +29,8 @@ ### A1. Private safety backup (before anything destructive) `git bundle create ../goodwebtools-full-history-.bundle --all` → a single-file, complete copy of all 426 commits + refs, stored outside the repo (private). Recoverable via `git clone `. Optionally also push all refs to a private backup remote. This is insurance only; not published. +**Prerequisite gate:** the Tauri updater signing key (`~/.tauri/gwt-updater.key` — the only copy) must be backed up before the release/public steps, since losing it permanently breaks the auto-updater for installed apps. (Confirmed backed up 2026-07-26.) The key is **not** in the repo, so going public does not expose it. + ### A2. `design-history` branch (public, clean) A single **orphan** commit containing only the internal planning artifacts: - `docs/superpowers/**` (specs + plans) @@ -43,7 +45,7 @@ After all cleanup (B/C/D) is merged to `main` and the deploy is verified: - `develop` is reset to match `main` (single commit) so the two branches share the clean base going forward. ### A4. Tag `desktop-v1.0.0-beta.1` -Deleted locally and on origin (`git push origin :refs/tags/desktop-v1.0.0-beta.1`), and its draft GitHub Release removed. It points at soon-to-be-purged commits and never published assets (billing-blocked). Desktop can be re-tagged fresh from the new history later. +The old tag is deleted locally and on origin (`git push origin :refs/tags/desktop-v1.0.0-beta.1`) and its draft GitHub Release removed — it points at soon-to-be-purged commits and never published assets (was billing-blocked). It is then **re-cut fresh after the repo goes public** (see E9): going public removes the Actions billing constraint, so tagging `desktop-v1.0.0-beta.1` from the new clean history finally builds + signs + publishes the installers. **Interface — Produces:** a clean public `main` (1 commit), a public `design-history` branch, a private history bundle. **Consumes:** the fully-cleaned tree from B/C/D. @@ -107,10 +109,11 @@ This is separate from `release.yml` (desktop) and does not deploy. 4. **Verify staging** — push `develop`; confirm Cloudflare staging build succeeds, `npm test`/`build`/`lint` green. 5. **Promote** — PR `develop`→`main`, merge; confirm **production** still deploys and GA loads (with `SITE_GA_ID` set in Cloudflare). 6. **Purge history** — squash `main` to one commit + force-push (A3); reset `develop` to match; delete the desktop tag/release (A4). -7. **Go public** — `gh repo edit --visibility public`. +7. **Go public** — `gh repo edit --visibility public`. This also **removes the GitHub Actions billing constraint** (Actions is free/unlimited on standard runners for public repos), unblocking the desktop release. 8. **Repo settings** — Component F. +9. **Cut the desktop release (post-public)** — with signing secrets already set and Actions now free: `git push origin desktop-v1.0.0-beta.1` → `release.yml` builds + signs macOS/Windows/Linux installers and publishes the GitHub Release. Verify all platform artifacts attach. (Secrets stay private; the tag trigger is maintainer-only, so fork PRs never touch them.) -Steps 1–5 are reversible; the irreversible steps (6–7) happen only after the deploy is proven green on the cleaned tree. +Steps 1–5 are reversible; the irreversible steps (6–7) happen only after the deploy is proven green on the cleaned tree. Step 9 is optional/independent and can be deferred. --- @@ -136,6 +139,6 @@ Via `gh` / dashboard, after going public: ## Out of scope (YAGNI) - Migrating deploys to a different provider or a second repo. -- A new desktop release (billing-blocked; separate effort). +- Desktop-app *code* changes (the desktop release in E9 just tags/builds the existing app; FFmpeg sidecar bundling remains deferred). - Marketing/launch posts, website redesign, logo/social-preview art (owner follow-up). - Rewriting tool code for style; only infra/docs/scaffolding change here. From 5f9e0b91c4ebdb698e614f422bf828cd3f590ea1 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 21:56:48 +0700 Subject: [PATCH 03/11] docs(plan): open-sourcing GoodWebTools implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-26-open-source-repo.md | 943 ++++++++++++++++++ 1 file changed, 943 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-open-source-repo.md diff --git a/docs/superpowers/plans/2026-07-26-open-source-repo.md b/docs/superpowers/plans/2026-07-26-open-source-repo.md new file mode 100644 index 0000000..8b65448 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-open-source-repo.md @@ -0,0 +1,943 @@ +# Open-Sourcing GoodWebTools Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Take `slaveofcode/goodwebtools` public with a clean single-commit history, genericized self-hostable infra, full contribution scaffolding, and PR CI — without disrupting the live Cloudflare deploy. + +**Architecture:** Ops/docs work, not application code. Reversible prep (backup, branch, genericize, community files, CI) is verified on staging→production *first*; only then are the irreversible steps (history squash, go-public) performed. This is a `gh`/`git`-heavy plan; "verification" replaces "tests" for most tasks, plus the existing `npm test`/`build`/`lint` where code changes. + +**Tech Stack:** git, GitHub CLI (`gh`), Astro/Vite build, Cloudflare Workers Builds (auto-deploy), GitHub Actions (CI + desktop release). + +## Global Constraints + +- **`gh` must be prefixed with `env -u GITHUB_TOKEN`** on this repo (the `GITHUB_TOKEN` env var lacks admin scope; the keyring token has it). +- **The live site must stay deployable at every step.** `main`=production, `develop`=staging, auto-deployed by Cloudflare Workers Builds on push. +- **Nothing sensitive reaches the public repo:** history purge + infra cleanup complete *before* visibility flips to public. History is already secret-free (verified — no `.env`/keys/tokens ever committed). +- **Genericize, don't break:** only the GA measurement ID moves to an env var (`SITE_GA_ID`). Other owner values (bucket/worker names, `goodwebtools.com`, updater pubkey) stay concrete but documented as owner-specific. +- **Irreversible steps (Tasks 11–12) require explicit human go-ahead** before running. +- Repo identity: `slaveofcode/goodwebtools`. Lint: `npm run lint` (`eslint src --ext .ts,.tsx,.astro`). Install: `npm i --legacy-peer-deps` (or `npm ci`, `.npmrc` sets legacy-peer-deps). +- Start on branch `develop`. + +--- + +## File Structure + +``` +# NEW (community health + CI) +LICENSE MIT +CONTRIBUTING.md contributor guide + "add a tool" +CODE_OF_CONDUCT.md Contributor Covenant v2.1 +SECURITY.md private disclosure via GitHub advisories +.github/ISSUE_TEMPLATE/bug_report.yml +.github/ISSUE_TEMPLATE/feature_request.yml +.github/ISSUE_TEMPLATE/config.yml +.github/PULL_REQUEST_TEMPLATE.md +.github/workflows/ci.yml test + build + lint on PRs + +# MODIFIED (genericize / document) +astro.config.mjs GA ID → process.env.SITE_GA_ID +wrangler.jsonc header comment: owner-specific, rename to self-host +.env.example document SITE_GA_ID +README.md rewritten public-facing +DEPLOYMENT.md / DEPLOYMENT-GIT.md add "Deploy your own instance" section +RELEASING-DESKTOP.md note: owner-specific signing/endpoints + +# REMOVED from main tree (preserved on design-history branch) +docs/superpowers/** internal specs + plans +plan.md 38KB internal planning + +# GIT/REPO OPS (no file content) +git bundle (private backup) · design-history branch · squash main · delete desktop tag +gh repo edit --visibility public · topics · discussions · branch protection +``` + +--- + +## Task 1: Pre-flight backups & safety checks + +**Files:** none (produces a private bundle outside the repo). + +**Interfaces:** Produces `../goodwebtools-full-history.bundle` (recoverable full history). Consumes nothing. + +- [ ] **Step 1: Confirm clean tree on develop** + +Run: `git checkout develop && git status --short && git log --oneline -1` +Expected: no output from `status` (clean); on `develop`. + +- [ ] **Step 2: Create the private full-history bundle (insurance)** + +Run: +```bash +git bundle create ../goodwebtools-full-history.bundle --all +git bundle verify ../goodwebtools-full-history.bundle +``` +Expected: `The bundle records a complete history` / `is okay`. This file is the recovery point for all 426 commits — keep it outside the repo (do NOT commit it). + +- [ ] **Step 3: Confirm the Tauri signing key is backed up** + +Run: `ls -la ~/.tauri/gwt-updater.key ~/.tauri/gwt-updater.key.pub` +Expected: both files exist. (User confirmed an external backup on 2026-07-26 — do not proceed to Task 11/12 otherwise.) + +- [ ] **Step 4: Record the current remote refs (for reference)** + +Run: `git ls-remote --heads --tags origin > ../goodwebtools-refs-before.txt && cat ../goodwebtools-refs-before.txt` +Expected: lists `main`, `develop`, `design-history` (later), and tag `desktop-v1.0.0-beta.1`. Kept as a private record; not committed. + +--- + +## Task 2: Create the `design-history` branch (preserve internal docs) + +**Files:** none in the working tree yet (operates on an orphan branch). + +**Interfaces:** Produces a pushed `design-history` branch containing `docs/superpowers/**` + `plan.md` + a branch README. Consumes: Task 1 backup. + +- [ ] **Step 1: Create an orphan branch and clear the index** + +Run: +```bash +git checkout --orphan design-history +git rm -rf --quiet . +``` +Expected: working tree emptied from git's view (files remain on disk until next step overwrites the index). + +- [ ] **Step 2: Restore only the internal planning artifacts from develop** + +Run: +```bash +git checkout develop -- docs/superpowers plan.md +``` +Expected: `docs/superpowers/` and `plan.md` staged. + +- [ ] **Step 3: Add a branch README explaining its purpose** + +Create `README.md` (on this branch only): +```markdown +# Design history + +This branch preserves the internal design specs and implementation plans that +guided GoodWebTools' development (`docs/superpowers/`) plus the original +`plan.md`. It is kept off `main` for a clean public presentation. These are +historical planning artifacts, not current documentation — see `main` for the +project and its docs. +``` +Run: `git add README.md` + +- [ ] **Step 4: Commit and push the branch** + +Run: +```bash +git commit -q -m "docs: preserve design specs and plans (design-history)" +git push -u origin design-history +``` +Expected: branch pushed. Verify: `env -u GITHUB_TOKEN gh api repos/slaveofcode/goodwebtools/branches/design-history --jq .name` → `design-history`. + +- [ ] **Step 5: Return to develop** + +Run: `git checkout develop` +Expected: back on `develop`, all files intact. + +--- + +## Task 3: Genericize the GA measurement ID + +**Files:** +- Modify: `astro.config.mjs` (the `PROD_GA_ID` line) +- Modify: `.env.example` + +**Interfaces:** Consumes: existing branch-gating in `astro.config.mjs`. Produces: GA loads on `main` builds only when `SITE_GA_ID` env is set (owner sets it in Cloudflare; forks get none). + +- [ ] **Step 1: Replace the hardcoded ID with an env read** + +In `astro.config.mjs`, change: +```js +const PROD_GA_ID = 'G-4Q9F8CL7FW'; +``` +to: +```js +// The production Google Analytics ID is provided by the deploy environment +// (SITE_GA_ID build var), not hard-coded — so forks never report to the +// upstream Analytics property. Owner sets SITE_GA_ID on the production build. +const PROD_GA_ID = process.env.SITE_GA_ID || ''; +``` +Leave the rest of the block (the `WORKERS_CI` / branch gating) unchanged. + +- [ ] **Step 2: Document SITE_GA_ID in .env.example** + +Append to `.env.example`: +```bash + +# Production Google Analytics 4 ID, injected by the deploy env (e.g. Cloudflare +# build variable SITE_GA_ID). Consumed by astro.config.mjs on `main` builds. +# Leave unset to disable analytics. Local dev: set PUBLIC_GA_ID above instead. +SITE_GA_ID= +``` + +- [ ] **Step 3: Verify the production path still inlines GA when SITE_GA_ID is set** + +Run: +```bash +SITE_GA_ID=G-4Q9F8CL7FW WORKERS_CI=1 WORKERS_CI_BRANCH=main npm run build > /tmp/ga-prod.log 2>&1; echo "exit:$?" +grep -rl "G-4Q9F8CL7FW" dist | wc -l +``` +Expected: exit 0; count > 0 (GA inlined). + +- [ ] **Step 4: Verify forks/no-var builds get NO GA** + +Run: +```bash +WORKERS_CI=1 WORKERS_CI_BRANCH=main npm run build > /tmp/ga-nofork.log 2>&1; echo "exit:$?" +grep -rl "G-4Q9F8CL7FW" dist | wc -l +``` +Expected: exit 0; count = 0 (no SITE_GA_ID → no GA, even on main). + +- [ ] **Step 5: Commit** + +```bash +git add astro.config.mjs .env.example +git commit -m "refactor(analytics): read production GA ID from SITE_GA_ID env + +Removes the hard-coded measurement ID from the source so public forks don't +report to the upstream Analytics property. Owner sets SITE_GA_ID as a Cloudflare +production build variable." +``` + +> **Owner action (out-of-band, before/at go-live):** set `SITE_GA_ID=G-4Q9F8CL7FW` as a **production build variable** in the production Worker's Workers Builds settings, so the live site keeps reporting analytics. + +--- + +## Task 4: Document owner-specific infra (wrangler, desktop, deploy docs) + +**Files:** +- Modify: `wrangler.jsonc` (header comment) +- Modify: `RELEASING-DESKTOP.md` (owner-specific note) +- Modify: `DEPLOYMENT.md` (add "Deploy your own instance" section) + +**Interfaces:** Consumes: nothing. Produces: fork-facing documentation. No functional/deploy change. + +- [ ] **Step 1: Add a self-host header comment to wrangler.jsonc** + +At the very top of `wrangler.jsonc` (before the opening `{`), JSONC allows a leading comment — insert: +```jsonc +// NOTE: The worker names, R2 bucket names, and domain below are specific to the +// upstream GoodWebTools deployment. To self-host, rename them to your own (and +// create your own R2 buckets — see DEPLOYMENT.md "Deploy your own instance"). +``` +(Keep all existing config unchanged.) + +- [ ] **Step 2: Verify wrangler.jsonc still parses** + +Run: `node -e "const fs=require('fs');JSON.parse(fs.readFileSync('wrangler.jsonc','utf8').replace(/^\s*\/\/.*$/gm,''));console.log('valid')"` +Expected: `valid`. + +- [ ] **Step 3: Add an owner-specific note to RELEASING-DESKTOP.md** + +At the top of `RELEASING-DESKTOP.md`, after the first heading, insert: +```markdown +> **Self-hosting note:** The signing key, GitHub secrets, and updater endpoints +> below are specific to the upstream release. A fork building its own desktop +> app must generate its own signing key (`npm run tauri -- signer generate`), +> set its own `TAURI_SIGNING_PRIVATE_KEY` secret, and point the updater endpoints +> in `src-tauri/tauri.conf.json` at its own releases. The upstream private key is +> never in this repo. +``` + +- [ ] **Step 4: Add a "Deploy your own instance" section to DEPLOYMENT.md** + +Append to `DEPLOYMENT.md`: +```markdown + +## Deploy your own instance + +GoodWebTools is self-hostable on Cloudflare Workers. To run your own copy: + +1. **Fork** this repo and clone it. +2. **Rename the deployment identifiers** in `wrangler.jsonc` (`name`, + `env.staging.name`) and the R2 bucket names to values you own. +3. **Create the R2 buckets:** + `npx wrangler r2 bucket create ` (and a `-staging` one). +4. **Point branding at your domain** in `src/config.ts` (`SITE_URL`, `REPO_URL`) + and `astro.config.mjs` (`site`); update `public/robots.txt`. +5. **(Optional) analytics:** set `SITE_GA_ID` as a production build variable + (Workers Builds → Settings → Build). Leave unset to disable. +6. **Stage & upload the ML models to R2:** `npm run stage:models` then + `npm run sync:r2` (see the section above). +7. **Connect Workers Builds** to your fork (production branch `main`, build + command `npm run build`) and push. + +Nothing sends data anywhere except your own Cloudflare account. +``` + +- [ ] **Step 5: Commit** + +```bash +git add wrangler.jsonc RELEASING-DESKTOP.md DEPLOYMENT.md +git commit -m "docs: mark owner-specific infra and add self-hosting guide" +``` + +--- + +## Task 5: Add the license and community-health files + +**Files:** +- Create: `LICENSE`, `CODE_OF_CONDUCT.md`, `SECURITY.md` +- Create: `.github/ISSUE_TEMPLATE/bug_report.yml`, `.github/ISSUE_TEMPLATE/feature_request.yml`, `.github/ISSUE_TEMPLATE/config.yml`, `.github/PULL_REQUEST_TEMPLATE.md` + +**Interfaces:** Consumes: nothing. Produces: the files GitHub surfaces in its community-health UI. + +- [ ] **Step 1: Create `LICENSE` (MIT)** + +``` +MIT License + +Copyright (c) 2026 slaveofcode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +- [ ] **Step 2: Create `CODE_OF_CONDUCT.md`** + +Write the **verbatim Contributor Covenant v2.1** (canonical source: https://www.contributor-covenant.org/version/2/1/code_of_conduct/code_of_conduct.md), with the single enforcement-contact line set to: +```markdown +reported to the community leaders responsible for enforcement via GitHub's +private vulnerability reporting on this repository, or by opening a confidential +report to the maintainers. +``` +(No email is published; enforcement routes through GitHub. The rest of the document is the standard Covenant text, unmodified.) + +- [ ] **Step 3: Create `SECURITY.md`** + +```markdown +# Security Policy + +GoodWebTools runs entirely in the browser — files never leave your device — so +the attack surface is small, but we take reports seriously. + +## Reporting a vulnerability + +Please **do not** open a public issue for security problems. Instead, use +GitHub's **private vulnerability reporting**: + +1. Go to the repository's **Security** tab. +2. Click **Report a vulnerability**. +3. Describe the issue and reproduction steps. + +We aim to acknowledge reports within a few days. Once a fix ships, we're happy +to credit you (unless you prefer to remain anonymous). + +## Supported versions + +The latest deployed version (`main`) is supported. There are no long-term +support branches. +``` + +- [ ] **Step 4: Create `.github/ISSUE_TEMPLATE/bug_report.yml`** + +```yaml +name: Bug report +description: Something in a tool is broken or behaves unexpectedly +labels: ["bug"] +body: + - type: input + id: tool + attributes: + label: Which tool? + placeholder: e.g. Image Compressor, DB Diagram, PDF to Image + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, what happened instead? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. Open the tool + 2. Drop file X + 3. Click Y + - type: input + id: env + attributes: + label: Browser / OS + placeholder: e.g. Chrome 130 on macOS 15 + - type: textarea + id: console + attributes: + label: Console errors (if any) + description: Open DevTools → Console and paste any errors. + render: shell +``` + +- [ ] **Step 5: Create `.github/ISSUE_TEMPLATE/feature_request.yml`** + +```yaml +name: Feature or tool request +description: Suggest a new tool or an improvement +labels: ["enhancement"] +body: + - type: textarea + id: idea + attributes: + label: What would you like? + description: Describe the tool or improvement and the problem it solves. + validations: + required: true + - type: checkboxes + id: constraints + attributes: + label: Fits the project? + options: + - label: This can work fully client-side (no server / no data leaving the browser) + required: true + - type: textarea + id: notes + attributes: + label: Anything else? + description: Prior art, links, or libraries that could help. +``` + +- [ ] **Step 6: Create `.github/ISSUE_TEMPLATE/config.yml`** + +```yaml +blank_issues_enabled: false +contact_links: + - name: Questions & ideas (Discussions) + url: https://github.com/slaveofcode/goodwebtools/discussions + about: Ask questions or discuss ideas before filing an issue. +``` + +- [ ] **Step 7: Create `.github/PULL_REQUEST_TEMPLATE.md`** + +```markdown +## What does this PR do? + + + +## Checklist + +- [ ] `npm test -- --run` passes +- [ ] `npm run build` succeeds +- [ ] `npm run lint` is clean +- [ ] New tool? It's registered in `src/registry/tools.ts` with an island + tests +- [ ] No owner-specific deploy assets changed (wrangler bucket names, secrets, domain) +- [ ] Everything still runs fully client-side (no data leaves the browser) +``` + +- [ ] **Step 8: Commit** + +```bash +git add LICENSE CODE_OF_CONDUCT.md SECURITY.md .github/ISSUE_TEMPLATE .github/PULL_REQUEST_TEMPLATE.md +git commit -m "docs: add MIT license and community-health files" +``` + +--- + +## Task 6: Write `CONTRIBUTING.md` (with the add-a-tool on-ramp) + +**Files:** Create: `CONTRIBUTING.md` + +**Interfaces:** Consumes: existing registry pattern (`src/registry/tools.ts`, `src/types/tool.ts`). Produces: the contributor entry point. + +- [ ] **Step 1: Create `CONTRIBUTING.md`** + +```markdown +# Contributing to GoodWebTools + +Thanks for wanting to help! GoodWebTools is a collection of privacy-first tools +that run entirely in the browser. Contributions — new tools, fixes, docs — are +welcome. + +## Setup + +```bash +git clone https://github.com/slaveofcode/goodwebtools +cd goodwebtools +npm install --legacy-peer-deps # a peer-dep conflict (tfjs/upscaler) needs this +npm run dev # http://localhost:4321 +``` + +Some tools need ML model files staged locally: `npm run stage:models`. + +## Checks (run before opening a PR) + +```bash +npm test -- --run # unit tests (Vitest) +npm run build # production build +npm run lint # ESLint +``` + +CI runs all three on every PR. + +## Branching + +- Base your work on `develop` (not `main`). Open PRs against `develop`. +- Keep PRs focused; one tool or fix per PR. + +## Adding a new tool + +Tools are self-registering. To add one: + +1. **Pure logic** → `src/tools//.lib.ts` with Vitest tests + (`.lib.test.ts`). Keep DOM/canvas out of the pure functions so they're + testable. +2. **UI island** → `src/islands//.tsx`, a default-exported React + component with **no required props**. Use the shared UI (`Dropzone`, + `ImageResult`/`ResultActions`, `usePasteImage`, etc.). +3. **Register it** in `src/registry/tools.ts` — append a `ToolDef`: + ```ts + { + id: 'my-tool', + name: 'My Tool', + category: 'Image', // Dev | PDF | Image | Files | Draw | Media | Playground + route: '/tools/my-tool', + keywords: ['...'], + icon: SomeLucideIcon, + summary: 'One-line description', + load: () => import('@/islands/image/MyTool'), + status: 'stable', + } + ``` + The route and page are generated automatically from the registry. + +That's it — no routing or page files to touch. + +## Principles + +- **Client-side only.** No servers, no uploads; user data never leaves the browser. +- **Follow existing patterns.** Match the surrounding code's style and structure. +- **Test the logic.** Pure `*.lib.ts` functions get unit tests. + +## Reporting bugs / ideas + +Use the issue templates, or start a [Discussion](https://github.com/slaveofcode/goodwebtools/discussions). +By contributing you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). +``` + +- [ ] **Step 2: Sanity-check the referenced categories are accurate** + +Run: `grep -oE "'(Dev|PDF|Image|Files|Draw|Media|Playground)'" src/types/tool.ts | sort -u` +Expected: the seven categories listed in the doc. Fix the doc if the set differs. + +- [ ] **Step 3: Commit** + +```bash +git add CONTRIBUTING.md +git commit -m "docs: add CONTRIBUTING with add-a-tool guide" +``` + +--- + +## Task 7: Rewrite `README.md` (public-facing) + +**Files:** Modify: `README.md` + +**Interfaces:** Consumes: tool registry (for the tool list), the new community files. Produces: the repo's landing page. + +- [ ] **Step 1: Replace README.md with a public-facing version** + +```markdown +# GoodWebTools + +Privacy-first daily-driver web tools that run **entirely in your browser** — your +files never leave your device. + +**Live:** https://goodwebtools.com · **Desktop app:** see [Releases](https://github.com/slaveofcode/goodwebtools/releases) + +![CI](https://github.com/slaveofcode/goodwebtools/actions/workflows/ci.yml/badge.svg) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) + +## Why + +Most "online tools" upload your files to a server. GoodWebTools does the work +client-side with WebAssembly, canvas, and on-device ML — so nothing is uploaded, +tracked, or stored remotely. + +## Tools + +Dozens of tools across **Dev**, **PDF**, **Image**, **Files**, **Draw**, **Media**, +and **Playground** categories — JSON/Base64/JWT utilities, image convert/compress/ +resize/annotate, background & object removal, PDF↔image, an Excalidraw whiteboard, +a DB-diagram (DBML) designer, QR tools, and more. Browse them all at +[goodwebtools.com](https://goodwebtools.com). + +## Tech + +- [Astro](https://astro.build) static site + **React islands** (per-tool, lazy-loaded) +- Client-side processing: WebAssembly, Canvas, `onnxruntime-web`, `@imgly/background-removal`, `mupdf`, `ffmpeg.wasm` +- Desktop app via [Tauri 2](https://tauri.app) +- Deployed on **Cloudflare Workers** (static assets + R2 for ML models) + +## Run locally + +```bash +npm install --legacy-peer-deps +npm run dev # http://localhost:4321 +``` + +## Contributing + +New tools and fixes welcome — the tool registry makes adding one straightforward. +See **[CONTRIBUTING.md](./CONTRIBUTING.md)** (includes an "add a tool" walkthrough) +and the **[Code of Conduct](./CODE_OF_CONDUCT.md)**. + +## Self-hosting + +You can run your own instance on Cloudflare — see the "Deploy your own instance" +section in **[DEPLOYMENT.md](./DEPLOYMENT.md)**. + +## License + +[MIT](./LICENSE) +``` + +- [ ] **Step 2: Verify no stale internal links remain** + +Run: `grep -nE "docs/superpowers|plan\.md" README.md || echo "clean"` +Expected: `clean` (the public README must not link to the removed internal docs). + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: rewrite README for public audience" +``` + +--- + +## Task 8: Add contributor CI workflow + +**Files:** Create: `.github/workflows/ci.yml` + +**Interfaces:** Consumes: `npm ci`, `npm test`, `npm run build`, `npm run lint`. Produces: a required status check for branch protection (Task 12). + +- [ ] **Step 1: Create `.github/workflows/ci.yml`** + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + verify: + name: Test · Build · Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Test + run: npm test -- --run + - name: Build + run: npm run build + - name: Lint + run: npm run lint +``` +(No `NODE_OPTIONS` needed — the `build` script already sets `--max-old-space-size=8192` via `cross-env`. `npm ci` respects `.npmrc`'s `legacy-peer-deps`.) + +- [ ] **Step 2: Validate the workflow YAML locally** + +Run: `node -e "require('fs').readFileSync('.github/workflows/ci.yml','utf8')" && python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('yaml ok')"` +Expected: `yaml ok` (or, if no python3/yaml, confirm indentation matches the block above). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: run test, build, and lint on pull requests" +``` + +--- + +## Task 9: Remove internal planning docs from the main tree + +**Files:** Delete from working tree: `docs/superpowers/**`, `plan.md` (preserved on `design-history` from Task 2). + +**Interfaces:** Consumes: Task 2 (docs already safe on `design-history`). Produces: a clean tree with no internal planning artifacts. + +- [ ] **Step 1: Confirm the docs are safe on design-history first** + +Run: `env -u GITHUB_TOKEN gh api repos/slaveofcode/goodwebtools/contents/plan.md?ref=design-history --jq .name` +Expected: `plan.md` (proves the branch has them before we delete on develop). + +- [ ] **Step 2: Remove the internal docs** + +Run: +```bash +git rm -r --quiet docs/superpowers +git rm --quiet plan.md +``` +Expected: staged deletions. (Note: this plan file lives under `docs/superpowers/plans/` and is itself removed here — it remains on `design-history` and in your local checkout of that branch.) + +- [ ] **Step 3: Commit** + +```bash +git commit -m "docs: move internal design specs/plans to design-history branch" +``` + +--- + +## Task 10: Verify on develop, then promote to main (staging → production) + +**Files:** none (verification + merge). + +**Interfaces:** Consumes: Tasks 3–9. Produces: a cleaned, deploy-verified `main`. + +- [ ] **Step 1: Full local verification** + +Run: +```bash +npm test -- --run 2>&1 | tail -3 +npm run build > /tmp/final-build.log 2>&1; echo "build exit:$?" +npm run lint 2>&1 | tail -5; echo "lint exit:$?" +``` +Expected: tests pass (429); build exit 0; lint exit 0. + +- [ ] **Step 2: Push develop → verify staging deploy** + +Run: `git push origin develop` +Then in the Cloudflare dashboard (or `env -u GITHUB_TOKEN gh api ...` if wired) confirm the **staging** Workers Build succeeds. Expected: green build; staging site serves (`curl -sI https:/// | head -1` → `HTTP/2 200`). + +- [ ] **Step 3: Open and merge develop → main** + +Run: +```bash +env -u GITHUB_TOKEN gh pr create --base main --head develop \ + --title "chore: prepare repo for open-source (docs, CI, genericized infra)" \ + --body "Community files, contributor CI, GA ID → SITE_GA_ID, self-hosting docs, internal docs moved to design-history. History purge + go-public follow separately." +env -u GITHUB_TOKEN gh pr merge --merge # use the PR number printed above if prompted +``` +Expected: merged. + +- [ ] **Step 4: Verify production still deploys and GA works** + +Confirm the **production** Workers Build (from `main`) succeeds. With `SITE_GA_ID` set in the production build vars, GA should load. Expected: `curl -s https://goodwebtools.com/ | grep -c "G-4Q9F8CL7FW"` → ≥1 (after the deploy completes and consent is granted in-browser; the ID is inlined in the HTML regardless). + +- [ ] **Step 5: Sync local branches** + +Run: `git fetch origin && git checkout main && git merge --ff-only origin/main && git checkout develop && git merge --ff-only origin/develop` +Expected: both fast-forward, in sync. + +**STOP GATE:** Do not proceed to Task 11 until Steps 2 and 4 are confirmed green and a human has given explicit go-ahead for the irreversible history purge. + +--- + +## Task 11: Purge history — squash `main` to one commit + +**Files:** none (history rewrite). **⚠️ IRREVERSIBLE — requires explicit human go-ahead.** + +**Interfaces:** Consumes: Task 10 (verified `main`), Task 1 (backup bundle). Produces: single-commit `main` + `develop`; deleted desktop tag. + +- [ ] **Step 1: Re-confirm the backup exists** + +Run: `git bundle verify ../goodwebtools-full-history.bundle | tail -1` +Expected: `is okay`. Abort if missing. + +- [ ] **Step 2: Squash main to a single clean commit** + +Run: +```bash +git checkout main && git pull --ff-only +git checkout --orphan public-main +git add -A +git commit -q -m "chore: initial public release + +GoodWebTools — privacy-first, client-side web tools. See CONTRIBUTING.md." +git branch -M public-main main +``` +Expected: `main` now has exactly one commit. Verify: `git log --oneline main | wc -l` → `1`. Confirm no AI trailers: `git log -1 --format='%an <%ae>%n%b' main` shows the owner and no `Co-Authored-By`. + +- [ ] **Step 3: Force-push the rewritten main** + +Run: `git push --force-with-lease origin main` +Expected: `+ ... main -> main (forced update)`. Cloudflare will rebuild production from the new HEAD (same tree → same deploy). + +- [ ] **Step 4: Reset develop to match, force-push** + +Run: +```bash +git checkout develop +git reset --hard main +git push --force-with-lease origin develop +``` +Expected: `develop` now equals the single-commit `main`. + +- [ ] **Step 5: Delete the desktop tag and its release** + +Run: +```bash +git push origin :refs/tags/desktop-v1.0.0-beta.1 || true +git tag -d desktop-v1.0.0-beta.1 || true +env -u GITHUB_TOKEN gh release delete desktop-v1.0.0-beta.1 --yes --cleanup-tag || true +``` +Expected: tag/release removed (ignore "not found" — it may have been a draft). + +- [ ] **Step 6: Verify production deploy recovered** + +Confirm the production Workers Build re-ran green on the new `main`. Expected: `curl -sI https://goodwebtools.com/ | head -1` → `HTTP/2 200`. + +--- + +## Task 12: Go public + repo settings + branch protection + +**Files:** none (`gh` repo settings). **⚠️ Going public is effectively irreversible (mirrors/indexing). Requires explicit human go-ahead.** + +**Interfaces:** Consumes: Tasks 8 (CI check name `verify`), 11 (clean history). Produces: a public, contribution-ready repo. + +- [ ] **Step 1: Flip visibility to public** + +Run: `env -u GITHUB_TOKEN gh repo edit slaveofcode/goodwebtools --visibility public --accept-visibility-change-consequences` +Expected: no error. Verify: `env -u GITHUB_TOKEN gh repo view slaveofcode/goodwebtools --json visibility --jq .visibility` → `public`. + +- [ ] **Step 2: Set description, homepage, and topics** + +Run: +```bash +env -u GITHUB_TOKEN gh repo edit slaveofcode/goodwebtools \ + --description "Privacy-first daily-driver web tools that run entirely in your browser." \ + --homepage "https://goodwebtools.com" +env -u GITHUB_TOKEN gh repo edit slaveofcode/goodwebtools \ + --add-topic astro --add-topic react --add-topic privacy --add-topic web-tools \ + --add-topic client-side --add-topic tauri --add-topic pdf --add-topic image-tools \ + --add-topic developer-tools --add-topic offline +``` +Expected: no error. + +- [ ] **Step 3: Enable Issues and Discussions** + +Run: +```bash +env -u GITHUB_TOKEN gh repo edit slaveofcode/goodwebtools --enable-issues --enable-discussions +``` +Expected: no error. Verify Discussions: `env -u GITHUB_TOKEN gh repo view slaveofcode/goodwebtools --json hasDiscussionsEnabled --jq .hasDiscussionsEnabled` → `true`. + +- [ ] **Step 4: Enable private vulnerability reporting** + +Run: `env -u GITHUB_TOKEN gh api -X PUT repos/slaveofcode/goodwebtools/private-vulnerability-reporting` +Expected: 204 no content (enables the "Report a vulnerability" button SECURITY.md points to). + +- [ ] **Step 5: Protect the `main` branch (require CI + review)** + +Run: +```bash +env -u GITHUB_TOKEN gh api -X PUT repos/slaveofcode/goodwebtools/branches/main/protection \ + --input - <<'JSON' +{ + "required_status_checks": { "strict": true, "contexts": ["verify"] }, + "enforce_admins": false, + "required_pull_request_reviews": { "required_approving_review_count": 1 }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false +} +JSON +``` +Expected: JSON describing the protection. (The status-check context `verify` matches the CI job name from Task 8; it becomes selectable once CI has run at least once — Step 6 triggers it.) + +- [ ] **Step 6: Open a smoke-test PR to confirm CI runs** + +Run: +```bash +git checkout develop && git checkout -b test/ci-smoke +git commit --allow-empty -m "test: trigger CI" +git push -u origin test/ci-smoke +env -u GITHUB_TOKEN gh pr create --base develop --head test/ci-smoke --title "test: CI smoke" --body "Verifying CI runs." +``` +Watch: `env -u GITHUB_TOKEN gh pr checks test/ci-smoke --watch` +Expected: the `verify` job runs and passes. Then close + delete the branch: +```bash +env -u GITHUB_TOKEN gh pr close test/ci-smoke --delete-branch +git checkout develop +``` + +- [ ] **Step 7: Final public-readiness check** + +Verify the community profile is complete: +```bash +env -u GITHUB_TOKEN gh api repos/slaveofcode/goodwebtools/community/profile --jq '.health_percentage, .files | keys' +``` +Expected: high health percentage; keys include `code_of_conduct`, `contributing`, `license`, `readme`, `issue_template`, `pull_request_template`. + +--- + +## Task 13: (Optional) Cut the desktop release post-public + +**Files:** none (tag push triggers `release.yml`). Independent; can be deferred. + +**Interfaces:** Consumes: Task 12 (public repo → free Actions), existing `TAURI_SIGNING_PRIVATE_KEY` secret + `release.yml`. Produces: a published GitHub Release with signed installers. + +- [ ] **Step 1: Confirm signing secrets are set** + +Run: `env -u GITHUB_TOKEN gh secret list --repo slaveofcode/goodwebtools | grep -i TAURI` +Expected: `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` listed. + +- [ ] **Step 2: Tag and push to trigger the release build** + +Run: +```bash +git checkout main +git tag desktop-v1.0.0-beta.1 +git push origin desktop-v1.0.0-beta.1 +``` +Expected: the `Release Desktop App` workflow starts (`env -u GITHUB_TOKEN gh run list --workflow=release.yml -L 1`). + +- [ ] **Step 3: Watch the build and verify artifacts** + +Run: `env -u GITHUB_TOKEN gh run watch $(env -u GITHUB_TOKEN gh run list --workflow=release.yml -L 1 --json databaseId --jq '.[0].databaseId')` +Expected: all four matrix jobs (macOS ×2, Windows, Linux) succeed. Then confirm the release has installers: +```bash +env -u GITHUB_TOKEN gh release view desktop-v1.0.0-beta.1 --json assets --jq '.assets[].name' +``` +Expected: `.dmg` (×2), `.exe` (NSIS), `.deb`, and `latest.json`. + +- [ ] **Step 4: Publish the release (if created as draft)** + +Run: `env -u GITHUB_TOKEN gh release edit desktop-v1.0.0-beta.1 --draft=false --prerelease` +Expected: release is public + marked pre-release. + +--- + +## Self-Review + +**1. Spec coverage:** +- A1 backup + signing-key gate → Task 1. ✓ +- A2 design-history branch → Task 2. ✓ +- A3 squash main → Task 11. ✓ +- A4 delete desktop tag + re-cut → Tasks 11.5, 13. ✓ +- B1 GA→SITE_GA_ID → Task 3. ✓ +- B2 wrangler doc → Task 4. ✓ · B3 domain doc → Task 4 (DEPLOYMENT self-host) + noted. ✓ · B4 desktop doc → Task 4. ✓ · B5 scripts unchanged → (no task needed, explicitly untouched). ✓ · B6 self-hosting guide → Task 4. ✓ +- C license + community files → Tasks 5, 6; README → Task 7. ✓ +- D CI → Task 8. ✓ +- E sequencing (staging→prod before purge, then public) → Tasks 9→10→11→12, with STOP GATE. ✓ +- F repo settings/branch protection/discussions → Task 12. ✓ +- E9 desktop release → Task 13. ✓ + +**2. Placeholder scan:** No "TBD/TODO". The CoC step references the canonical Contributor Covenant v2.1 (a fixed standard document) with an exact enforcement-contact line — deterministic, not a placeholder. All file contents are complete. + +**3. Consistency:** CI job name `verify` (Task 8) matches the branch-protection status-check context (Task 12 Step 5). `SITE_GA_ID` used identically in Task 3 (astro.config + .env.example) and the owner-action note + Task 10 verification. `design-history` branch created in Task 2 is the safety precondition checked in Task 9 Step 1. The backup bundle path `../goodwebtools-full-history.bundle` is consistent across Tasks 1 and 11. Repo slug `slaveofcode/goodwebtools` used throughout. ✓ From 5d72c474db9c0e59ef586bf31fd451e84f47d688 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:02:33 +0700 Subject: [PATCH 04/11] refactor(analytics): read production GA ID from SITE_GA_ID env Removes the hard-coded measurement ID from the source so public forks don't report to the upstream Analytics property. Owner sets SITE_GA_ID as a Cloudflare production build variable. --- .env.example | 5 +++++ astro.config.mjs | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 189aa27..d7a0209 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,8 @@ PUBLIC_GA_ID= # Set to 1 to emit robots noindex on every page. Auto-set to 1 for non-main # branch builds in CI; leave empty otherwise. PUBLIC_NOINDEX= + +# Production Google Analytics 4 ID, injected by the deploy env (e.g. Cloudflare +# build variable SITE_GA_ID). Consumed by astro.config.mjs on `main` builds. +# Leave unset to disable analytics. Local dev: set PUBLIC_GA_ID above instead. +SITE_GA_ID= diff --git a/astro.config.mjs b/astro.config.mjs index 3775ffa..a2f9a16 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -13,7 +13,10 @@ import sitemap from '@astrojs/sitemap'; // // Only applied on Workers Builds (WORKERS_CI=1) so local/manual builds keep their // existing behavior. An explicitly-set PUBLIC_* env var always wins (overrides). -const PROD_GA_ID = 'G-4Q9F8CL7FW'; +// The production Google Analytics ID is provided by the deploy environment +// (SITE_GA_ID build var), not hard-coded — so forks never report to the +// upstream Analytics property. Owner sets SITE_GA_ID on the production build. +const PROD_GA_ID = process.env.SITE_GA_ID || ''; if (process.env.WORKERS_CI === '1') { const isProductionBranch = (process.env.WORKERS_CI_BRANCH || '') === 'main'; if (process.env.PUBLIC_GA_ID === undefined) { From b081af567207d76ab5222aa8b30e3d80de1d266a Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:03:17 +0700 Subject: [PATCH 05/11] docs: mark owner-specific infra and add self-hosting guide --- DEPLOYMENT.md | 20 ++++++++++++++++++++ RELEASING-DESKTOP.md | 7 +++++++ wrangler.jsonc | 3 +++ 3 files changed, 30 insertions(+) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 1a7b039..b97b0ca 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -172,3 +172,23 @@ npm run deploy # = npm run build && wrangler deploy Cloudflare's free tier covers this comfortably: static assets have unlimited bandwidth, and R2 has a generous free tier for storage + egress (models are immutable and hard-cached, so they download once per visitor). + +## Deploy your own instance + +GoodWebTools is self-hostable on Cloudflare Workers. To run your own copy: + +1. **Fork** this repo and clone it. +2. **Rename the deployment identifiers** in `wrangler.jsonc` (`name`, + `env.staging.name`) and the R2 bucket names to values you own. +3. **Create the R2 buckets:** + `npx wrangler r2 bucket create ` (and a `-staging` one). +4. **Point branding at your domain** in `src/config.ts` (`SITE_URL`, `REPO_URL`) + and `astro.config.mjs` (`site`); update `public/robots.txt`. +5. **(Optional) analytics:** set `SITE_GA_ID` as a production build variable + (Workers Builds → Settings → Build). Leave unset to disable. +6. **Stage & upload the ML models to R2:** `npm run stage:models` then + `npm run sync:r2` (see the section above). +7. **Connect Workers Builds** to your fork (production branch `main`, build + command `npm run build`) and push. + +Nothing sends data anywhere except your own Cloudflare account. diff --git a/RELEASING-DESKTOP.md b/RELEASING-DESKTOP.md index a568112..5c08515 100644 --- a/RELEASING-DESKTOP.md +++ b/RELEASING-DESKTOP.md @@ -2,6 +2,13 @@ How to build, sign, and publish a GoodWebTools desktop release. +> **Self-hosting note:** The signing key, GitHub secrets, and updater endpoints +> below are specific to the upstream release. A fork building its own desktop +> app must generate its own signing key (`npm run tauri -- signer generate`), +> set its own `TAURI_SIGNING_PRIVATE_KEY` secret, and point the updater endpoints +> in `src-tauri/tauri.conf.json` at its own releases. The upstream private key is +> never in this repo. + ## Prerequisites - Rust + Cargo (stable) diff --git a/wrangler.jsonc b/wrangler.jsonc index c5493ea..e35073d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,3 +1,6 @@ +// NOTE: The worker names, R2 bucket names, and domain below are specific to the +// upstream GoodWebTools deployment. To self-host, rename them to your own (and +// create your own R2 buckets — see DEPLOYMENT.md "Deploy your own instance"). { // Deploys the static Astro build (dist/) plus a small Worker that streams ML // model files from R2 at /models/*. Build first (`npm run build`), then From 847c9a6a0751688d8125d56f17d3cff40fb0b887 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:04:18 +0700 Subject: [PATCH 06/11] docs: add MIT license and community-health files --- .github/ISSUE_TEMPLATE/bug_report.yml | 37 ++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 23 ++++ .github/PULL_REQUEST_TEMPLATE.md | 12 ++ CODE_OF_CONDUCT.md | 133 +++++++++++++++++++++ LICENSE | 21 ++++ SECURITY.md | 21 ++++ 7 files changed, 252 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ff022bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,37 @@ +name: Bug report +description: Something in a tool is broken or behaves unexpectedly +labels: ["bug"] +body: + - type: input + id: tool + attributes: + label: Which tool? + placeholder: e.g. Image Compressor, DB Diagram, PDF to Image + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, what happened instead? + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. Open the tool + 2. Drop file X + 3. Click Y + - type: input + id: env + attributes: + label: Browser / OS + placeholder: e.g. Chrome 130 on macOS 15 + - type: textarea + id: console + attributes: + label: Console errors (if any) + description: Open DevTools → Console and paste any errors. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a649138 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & ideas (Discussions) + url: https://github.com/slaveofcode/goodwebtools/discussions + about: Ask questions or discuss ideas before filing an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..acfb769 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature or tool request +description: Suggest a new tool or an improvement +labels: ["enhancement"] +body: + - type: textarea + id: idea + attributes: + label: What would you like? + description: Describe the tool or improvement and the problem it solves. + validations: + required: true + - type: checkboxes + id: constraints + attributes: + label: Fits the project? + options: + - label: This can work fully client-side (no server / no data leaving the browser) + required: true + - type: textarea + id: notes + attributes: + label: Anything else? + description: Prior art, links, or libraries that could help. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..447a8b3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## What does this PR do? + + + +## Checklist + +- [ ] `npm test -- --run` passes +- [ ] `npm run build` succeeds +- [ ] `npm run lint` is clean +- [ ] New tool? It's registered in `src/registry/tools.ts` with an island + tests +- [ ] No owner-specific deploy assets changed (wrangler bucket names, secrets, domain) +- [ ] Everything still runs fully client-side (no data leaves the browser) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9331e6b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via GitHub's +private vulnerability reporting on this repository, or by opening a confidential +report to the maintainers. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..376429e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 slaveofcode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7ed6aeb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +GoodWebTools runs entirely in the browser — files never leave your device — so +the attack surface is small, but we take reports seriously. + +## Reporting a vulnerability + +Please **do not** open a public issue for security problems. Instead, use +GitHub's **private vulnerability reporting**: + +1. Go to the repository's **Security** tab. +2. Click **Report a vulnerability**. +3. Describe the issue and reproduction steps. + +We aim to acknowledge reports within a few days. Once a fix ships, we're happy +to credit you (unless you prefer to remain anonymous). + +## Supported versions + +The latest deployed version (`main`) is supported. There are no long-term +support branches. From 89cf8b7417549d4e334287c6138fbb9ae8327f68 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:04:45 +0700 Subject: [PATCH 07/11] docs: add CONTRIBUTING with add-a-tool guide --- CONTRIBUTING.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b83d302 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing to GoodWebTools + +Thanks for wanting to help! GoodWebTools is a collection of privacy-first tools +that run entirely in the browser. Contributions — new tools, fixes, docs — are +welcome. + +## Setup + +```bash +git clone https://github.com/slaveofcode/goodwebtools +cd goodwebtools +npm install --legacy-peer-deps # a peer-dep conflict (tfjs/upscaler) needs this +npm run dev # http://localhost:4321 +``` + +Some tools need ML model files staged locally: `npm run stage:models`. + +## Checks (run before opening a PR) + +```bash +npm test -- --run # unit tests (Vitest) +npm run build # production build +npm run lint # ESLint +``` + +CI runs all three on every PR. + +## Branching + +- Base your work on `develop` (not `main`). Open PRs against `develop`. +- Keep PRs focused; one tool or fix per PR. + +## Adding a new tool + +Tools are self-registering. To add one: + +1. **Pure logic** → `src/tools//.lib.ts` with Vitest tests + (`.lib.test.ts`). Keep DOM/canvas out of the pure functions so they're + testable. +2. **UI island** → `src/islands//.tsx`, a default-exported React + component with **no required props**. Use the shared UI (`Dropzone`, + `ImageResult`/`ResultActions`, `usePasteImage`, etc.). +3. **Register it** in `src/registry/tools.ts` — append a `ToolDef`: + ```ts + { + id: 'my-tool', + name: 'My Tool', + category: 'Image', // Dev | PDF | Image | Files | Draw | Media | Playground + route: '/tools/my-tool', + keywords: ['...'], + icon: SomeLucideIcon, + summary: 'One-line description', + load: () => import('@/islands/image/MyTool'), + status: 'stable', + } + ``` + The route and page are generated automatically from the registry. + +That's it — no routing or page files to touch. + +## Principles + +- **Client-side only.** No servers, no uploads; user data never leaves the browser. +- **Follow existing patterns.** Match the surrounding code's style and structure. +- **Test the logic.** Pure `*.lib.ts` functions get unit tests. + +## Reporting bugs / ideas + +Use the issue templates, or start a [Discussion](https://github.com/slaveofcode/goodwebtools/discussions). +By contributing you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). From acbc7a6afc99775840c7229b921e8f5a02a4d522 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:05:11 +0700 Subject: [PATCH 08/11] docs: rewrite README for public audience --- README.md | 248 +++++++----------------------------------------------- 1 file changed, 32 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index 272d1cc..e30edeb 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,52 @@ # GoodWebTools -Privacy-first client-side utilities. All processing happens in your browser. +Privacy-first daily-driver web tools that run **entirely in your browser** — your +files never leave your device. -## Features +**Live:** https://goodwebtools.com · **Desktop app:** see [Releases](https://github.com/slaveofcode/goodwebtools/releases) -- **100% Client-Side** - No file uploads, no servers -- **Works Offline** - Install as PWA (coming soon) -- **Open Source** - Audit the code yourself -- **Privacy-First** - Verify with DevTools Network tab +![CI](https://github.com/slaveofcode/goodwebtools/actions/workflows/ci.yml/badge.svg) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) -## Development +## Why -### Prerequisites +Most "online tools" upload your files to a server. GoodWebTools does the work +client-side with WebAssembly, canvas, and on-device ML — so nothing is uploaded, +tracked, or stored remotely. -- Node.js 20+ -- npm 10+ +## Tools -### Setup +Dozens of tools across **Dev**, **PDF**, **Image**, **Files**, **Draw**, **Media**, +and **Playground** categories — JSON/Base64/JWT utilities, image convert/compress/ +resize/annotate, background & object removal, PDF↔image, an Excalidraw whiteboard, +a DB-diagram (DBML) designer, QR tools, and more. Browse them all at +[goodwebtools.com](https://goodwebtools.com). -```bash -npm install -npm run dev -``` +## Tech -Open http://localhost:4321 - -### Scripts - -- `npm run dev` - Start dev server -- `npm run build` - Build for production -- `npm run preview` - Preview production build -- `npm run test` - Run tests -- `npm run lint` - Lint code -- `npm run lint:fix` - Fix linting issues -- `npm run format` - Format code with Prettier -- `npm run format:check` - Check code formatting - -### Architecture - -- **Astro** - Static site with View Transitions -- **React** - Islands for interactivity -- **Tailwind CSS** - Styling -- **Nanostores** - State management -- **Comlink** - Worker communication -- **Vitest** - Testing - -## Status - -✅ **Phase 0 — Foundation complete:** -- Tool registry with search + command palette (⌘K) -- Theme system (light/dark) -- Shared services (File, Worker, Asset, Download, Progress, Persistence) -- Registry-driven dynamic tool routing (`ToolHost`) - -✅ **Phase 1 — Dev utilities (17 tools):** -- JSON Formatter/Validator -- Base64 Encode/Decode -- URL Encode/Decode -- JWT Decoder (decode-only) -- UUID v4 Generator -- Password Generator (Bitwarden-style: unbiased RNG, guaranteed types, min numbers/special) -- Text Diff (line-level) -- CSV ↔ JSON converter (configurable delimiter: comma, semicolon, tab, pipe) -- JSON ↔ YAML converter -- JSON ↔ XML converter -- JSON ↔ TOML converter -- Number Base Converter (bin/oct/dec/hex) -- Color Converter (HEX/RGB/HSL) -- Markdown Preview (sanitized) -- QR Code Generator -- QR Code Reader -- Timestamp Converter -- Hash File (MD5 / SHA-1 / SHA-256 / SHA-512, streamed in a worker for large files) - -✅ **Phase 2 — PDF suite (11 tools):** -- Merge PDFs (reorderable) -- Split PDF (extract page range) -- Rotate PDF (90/180/270°) -- Delete PDF pages -- Watermark PDF (diagonal text) -- Images → PDF (PNG/JPG) -- PDF → Images (paginated, PNG/JPG, ZIP-all) -- Compress PDF -- Protect PDF (AES-256 password) -- Unlock PDF (remove password) - -Engine: **mupdf-wasm** (in a worker) parses/edits real-world PDFs that pdf-lib -can't; `pdfjs-dist` renders pages; `pdf-lib` builds images→PDF and draws -watermarks. All fully client-side. (mupdf is AGPL — fine while this stays open source.) - -## Design - -**Neo-Brutalism** — thick outlines, hard offset shadows, sharp corners, bold -Space Grotesk (self-hosted, same-origin to preserve zero external requests). -Fluid-width, mobile-first layout. - -✅ **Phase 3 — Image basics (8 tools):** -- Image Converter (PNG / JPEG / WebP / AVIF / GIF / ICO favicon / SVG) -- Image Compressor (quality, size delta) -- Image Resizer (aspect-lock) -- Image Cropper (persistent, resizable crop box) -- Merge Images (stack vertical / horizontal / grid with a column picker, reorderable, gap + background) -- Image Watermark (diagonal / tiled / corner) -- Image Annotator (arrows, shapes, text, highlighter, blur — Lark-style; Select to move / rename / delete) -- Metadata Scrubber (strip EXIF/GPS by re-encoding) - -All Canvas-based, fully client-side (`src/tools/image/canvas.lib.ts`). -Every image tool accepts a **paste from clipboard** (⌘/Ctrl+V) in, and every -result offers **Download** and **Copy to clipboard**. - -✅ **Phase 4 — Files & crypto (5 tools):** -- File Encrypt / Decrypt — password-lock any file with **AES-256-GCM** and a - PBKDF2 key (250k iterations, SHA-256). Self-describing `.gwtenc` container. - WebCrypto only, no dependencies. -- Zip / Unzip — bundle any files into a `.zip`, or extract one and download - individual entries. `fflate`, fully client-side. -- Archive Extractor — extract **RAR, 7z, TAR, GZ, ZIP** and more via - `libarchive.js` (WASM, self-hosted worker). Extract-only — creating .rar/.7z - isn't possible client-side (proprietary formats). -- File Split / Join — cut a large file into fixed-size parts (`.001`, `.002`…) - and rejoin them. Lazy `Blob.slice`, no full-file buffering. -- (Hash File gained MD5 / SHA-1 / SHA-256 / SHA-512 with chunked streaming.) - -✅ **Phase 5 — ML image tools (5 tools, on-device AI):** -- Background Remover — removes an image background with an **on-device AI model** - (ISNet via `@imgly/background-removal` + onnxruntime-web WASM). The image never - leaves the browser; the model (~40 MB) is served same-origin from **R2** and - cached. Outputs a transparent PNG. -- Face Blur — auto-detects faces (MediaPipe BlazeFace, ~230 KB) and hides them - with blur / pixelate / solid — all on-device. Great for anonymizing photos. -- Image Upscaler — enlarges images 2–4× with an **ESRGAN** super-resolution - model (UpscalerJS + TensorFlow.js, ~1 MB, tiled). On-device; caps input at - ~1.2 MP so the browser stays responsive. -- Portrait Blur — "portrait mode" bokeh: reuses the background-removal model to - keep the subject sharp and blur the background (adjustable strength). -- Object Remover **(experimental)** — paint over an object and erase it with - **LaMa** inpainting (onnxruntime-web). Big model (~200 MB) + a consent gate - warning about the download and hardware needs; on-device only. - -Model assets are hosted in a Cloudflare **R2** bucket (see `DEPLOYMENT.md`) to -stay same-origin without hitting the 25 MB static-asset limit. - -✅ **Phase 6 — Drawing (2 tools):** -- Whiteboard — infinite-canvas sketching, diagrams, flowcharts, and mind maps - (embeds **Excalidraw**; fonts self-hosted so there are still zero external - requests). Export PNG / SVG / `.excalidraw`. -- Signature Pad — draw a signature and export as PNG or SVG (`signature_pad`). - -✅ **Phase 7 — Media (6 tools):** -- Video → GIF — turn a video clip into an animated GIF (fps/width/trim, two-pass - palette for quality). -- Video Converter — convert / compress / trim / resize video between MP4 (H.264), - WebM (VP9) and MOV, with a CRF quality slider and optional audio drop. -- Video → Audio — rip the audio track out of a video to MP3 / M4A / WAV / Opus. -- Audio Converter — convert, re-encode (bitrate) or trim audio: MP3 / M4A / Opus / - WAV / FLAC. -- Screen Recorder — record a tab, window or the whole screen (optionally + mic) - with the native `MediaRecorder` — no WASM, nothing uploaded. -- Screenshot — capture the screen with a countdown, then drag a crop rectangle and - export PNG / JPG (native `getDisplayMedia` + canvas). - -The four ffmpeg tools share one **ffmpeg.wasm** engine (single-thread core, so no -cross-origin-isolation headers are needed), self-hosted from R2. The video/audio -never leaves your device. - -✅ **Phase 8 — Companion extension (optional):** -- [`extension/`](./extension) — a thin **MV3 capability-shim** that adds the few - things a web page can't do: a **global hotkey** screenshot (fires while another - app is focused), **cross-window desktop capture**, and a region-select overlay. - It bridges results back to the Screenshot tool via `window.postMessage`, so the - site stays fully usable without it (progressive enhancement). Least-privilege - permissions, nothing uploaded. See [extension/README.md](./extension/README.md). - -✅ **Phase 9 — Playground (2 tools, on-device dev sandboxes):** -- Code Scratchpad — a VS Code-grade **multi-file** editor on self-hosted - **Monaco**: native multi-cursor, move/copy line, column select, find & replace. - Open/save real files (File System Access API), autosaved to IndexedDB. -- SQLite Playground — a durable in-browser **SQLite** database - (`@sqlite.org/sqlite-wasm` + OPFS SAHPool, no COOP/COEP) with a schema explorer, - a SQL editor (⌘/Ctrl+Enter to run), and a **visual results grid**. DDL/DML show - a summary and refresh the schema; import/export `.sqlite`; a sample DB to explore. - -Both ride one lazily-loaded, self-hosted Monaco engine — never in the shell -payload. Nothing is uploaded. - -✅ **Phase 10 — Desktop app (Tauri 2):** -- **System-wide screenshot** — global hotkey (⌘⇧A), multi-display picker, region - selector overlay, countdown, main window hides during capture. -- **Screen + audio recording** — full-screen or bounded region capture via - platform screen APIs; audio recorded in parallel via FFmpeg (avfoundation / - dshow / pulse) and muxed into the final video on stop. -- **System tray** — lives in the macOS menu bar; left-click to focus, "Take - Screenshot" shortcut directly in the menu. -- **Native FFmpeg** — bundled sidecar (per-platform binary) with system fallback; - verified at startup via `bundle:check` script. -- **First-run wizard** — permission check (Screen Recording, Microphone, FFmpeg) - with one-click jump to System Preferences. -- **Settings** — desktop preferences (format, tray, launch-at-login), permission - status panel, and auto-updater UI. -- **Auto-updater** — checks GitHub Releases on demand; downloads and relaunches - in place. -- **GitHub Actions release pipeline** — matrix build for macOS (arm64 + x64), - Windows, and Linux on `desktop-v*` tags. -- **Download page** — `/download` auto-detects OS/arch and highlights the right - installer. - -### Desktop development +- [Astro](https://astro.build) static site + **React islands** (per-tool, lazy-loaded) +- Client-side processing: WebAssembly, Canvas, `onnxruntime-web`, `@imgly/background-removal`, `mupdf`, `ffmpeg.wasm` +- Desktop app via [Tauri 2](https://tauri.app) +- Deployed on **Cloudflare Workers** (static assets + R2 for ML models) + +## Run locally ```bash -npm run tauri:dev # Dev server + Tauri window -npm run tauri:build # Release bundle (requires icon assets + FFmpeg sidecar) -npm run download:ffmpeg # Download platform FFmpeg sidecars to src-tauri/bin/ -npm run bundle:check # Verify all required assets before release build +npm install --legacy-peer-deps +npm run dev # http://localhost:4321 ``` -See [RELEASING-DESKTOP.md](./RELEASING-DESKTOP.md) for the full release guide, -including FFmpeg sidecar setup and signing key configuration for GitHub Actions. - -## Testing +## Contributing -Unit tests cover pure tool logic and service layer: `npm run test` — **385 tests across 43 files**. +New tools and fixes welcome — the tool registry makes adding one straightforward. +See **[CONTRIBUTING.md](./CONTRIBUTING.md)** (includes an "add a tool" walkthrough) +and the **[Code of Conduct](./CODE_OF_CONDUCT.md)**. -## Deployment +## Self-hosting -Deployed to **Cloudflare Workers** (static assets) with model files streamed -from an **R2** bucket. See [DEPLOYMENT.md](./DEPLOYMENT.md) for the full guide. - -**Quick deploy:** -```bash -npm run deploy # = npm run build && wrangler deploy -``` +You can run your own instance on Cloudflare — see the "Deploy your own instance" +section in **[DEPLOYMENT.md](./DEPLOYMENT.md)**. ## License -MIT +[MIT](./LICENSE) From d2663f6363abfb87a8ecc9b88fcad50c438a9240 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:05:25 +0700 Subject: [PATCH 09/11] ci: run test, build, and lint on pull requests --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..060d106 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + push: + branches: [main, develop] + +jobs: + verify: + name: Test · Build · Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Test + run: npm test -- --run + - name: Build + run: npm run build + - name: Lint + run: npm run lint From a189ef1840fdce9575bd78cd9b51c4da1a22d19d Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 26 Jul 2026 22:05:41 +0700 Subject: [PATCH 10/11] docs: move internal design specs/plans to design-history branch --- .../plans/2026-07-12-phase0-foundation.md | 3352 ----------------- .../plans/2026-07-13-playground-tools.md | 1716 --------- .../plans/2026-07-14-tauri-desktop-app.md | 1085 ------ .../2026-07-20-optimize-desktop-screenshot.md | 103 - .../plans/2026-07-25-dbdiagram-tool.md | 1475 -------- ...07-25-image-tools-and-annotator-handoff.md | 1489 -------- .../plans/2026-07-26-open-source-repo.md | 943 ----- ...2-goodwebtools-phase0-foundation-design.md | 1380 ------- .../2026-07-13-playground-tools-design.md | 279 -- .../2026-07-14-tauri-desktop-app-design.md | 1821 --------- .../specs/2026-07-25-dbdiagram-tool-design.md | 151 - ...mage-tools-and-annotator-handoff-design.md | 153 - .../2026-07-26-open-source-repo-design.md | 144 - plan.md | 341 -- 14 files changed, 14432 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-12-phase0-foundation.md delete mode 100644 docs/superpowers/plans/2026-07-13-playground-tools.md delete mode 100644 docs/superpowers/plans/2026-07-14-tauri-desktop-app.md delete mode 100644 docs/superpowers/plans/2026-07-20-optimize-desktop-screenshot.md delete mode 100644 docs/superpowers/plans/2026-07-25-dbdiagram-tool.md delete mode 100644 docs/superpowers/plans/2026-07-25-image-tools-and-annotator-handoff.md delete mode 100644 docs/superpowers/plans/2026-07-26-open-source-repo.md delete mode 100644 docs/superpowers/specs/2026-07-12-goodwebtools-phase0-foundation-design.md delete mode 100644 docs/superpowers/specs/2026-07-13-playground-tools-design.md delete mode 100644 docs/superpowers/specs/2026-07-14-tauri-desktop-app-design.md delete mode 100644 docs/superpowers/specs/2026-07-25-dbdiagram-tool-design.md delete mode 100644 docs/superpowers/specs/2026-07-25-image-tools-and-annotator-handoff-design.md delete mode 100644 docs/superpowers/specs/2026-07-26-open-source-repo-design.md delete mode 100644 plan.md diff --git a/docs/superpowers/plans/2026-07-12-phase0-foundation.md b/docs/superpowers/plans/2026-07-12-phase0-foundation.md deleted file mode 100644 index eb4338e..0000000 --- a/docs/superpowers/plans/2026-07-12-phase0-foundation.md +++ /dev/null @@ -1,3352 +0,0 @@ -# Phase 0: Foundation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build the complete architectural foundation for GoodWebTools.com - a privacy-first, client-side utility suite with Astro + React + Tailwind, including tool registry, shared services, command palette, PWA support, and a validation demo tool. - -**Architecture:** Layered services architecture with Astro MPA + View Transitions, React islands for interactivity, six singleton services (File, Worker, Asset, Download, Progress, Persistence), persisted shell with cmdk command palette, and full PWA offline capability. - -**Tech Stack:** Astro 4.x, React 18, Tailwind CSS, Nanostores, Comlink, cmdk, @vite-pwa/astro, Vitest, Cloudflare Pages - -## Global Constraints - -- **Performance budget:** Initial shell < 120KB gzipped, FCP < 1.5s -- **Browser support:** Modern browsers only (Chrome/Edge/Firefox/Safari latest 2 versions) -- **Accessibility:** WCAG 2.1 AA compliance, keyboard-first navigation -- **Privacy:** No external network calls except same-origin assets, strict CSP -- **Code style:** ESLint + Prettier enforced, TypeScript strict mode -- **Testing:** Vitest for unit tests, manual E2E for Phase 0 -- **Commits:** Conventional commits format (`feat:`, `fix:`, `docs:`, etc.) -- **Variable naming:** Explicit units in names (byteSize, maxAgeMs, loadedBytes, etc.) - ---- - -## File Structure Overview - -``` -gwt/ -├── src/ -│ ├── pages/ -│ │ ├── index.astro # Homepage with tool grid -│ │ ├── privacy.astro # Privacy/verification page -│ │ └── tools/ -│ │ └── [tool].astro # Dynamic tool route -│ ├── layouts/ -│ │ └── Base.astro # Root layout with ViewTransitions -│ ├── components/ -│ │ ├── shell/ -│ │ │ ├── ShellIsland.tsx # Persisted shell -│ │ │ ├── CommandPalette.tsx # cmdk search -│ │ │ └── ThemeToggle.tsx # Light/dark toggle -│ │ └── ui/ -│ │ ├── Dropzone.tsx # File drag-drop -│ │ ├── ProgressBar.tsx # Progress indicator -│ │ ├── FileList.tsx # File list display -│ │ └── ResultActions.tsx # Download/copy buttons -│ ├── islands/ -│ │ └── demo/ -│ │ └── HashDemo.tsx # Hash tool island -│ ├── tools/ -│ │ └── demo/ -│ │ ├── hash.lib.ts # Hash logic -│ │ └── hash.worker.ts # Hash worker -│ ├── services/ -│ │ ├── file.service.ts # FileService -│ │ ├── worker.service.ts # WorkerPool -│ │ ├── asset.service.ts # AssetCache -│ │ ├── download.service.ts # DownloadService -│ │ ├── progress.service.ts # ProgressService -│ │ └── persistence.service.ts # PersistenceService -│ ├── registry/ -│ │ ├── tools.ts # Tool manifest -│ │ └── categories.ts # Category types -│ ├── hooks/ -│ │ ├── useWorker.ts # Worker integration hook -│ │ └── usePersistence.ts # Persistence hook -│ ├── stores/ -│ │ ├── theme.store.ts # Theme state -│ │ └── worker.store.ts # Worker status -│ ├── styles/ -│ │ └── global.css # Tailwind + theme vars -│ └── types/ -│ ├── tool.ts # ToolDef interface -│ └── service.ts # Service interfaces -├── public/ -│ ├── icon-192.png -│ ├── icon-512.png -│ └── manifest.json -├── _headers # Cloudflare headers -├── astro.config.mjs -├── tailwind.config.mjs -├── tsconfig.json -├── vitest.config.ts -├── .eslintrc.js -├── .prettierrc -├── README.md -├── CONTRIBUTING.md -└── docs/ - └── architecture.md -``` - ---- - -### Task 1: Project Initialization - -**Files:** -- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `.gitignore` -- Create: `src/pages/index.astro` (minimal) -- Create: `src/styles/global.css` - -**Interfaces:** -- Consumes: None (initial setup) -- Produces: Working Astro dev server, TypeScript configuration - -- [ ] **Step 1: Initialize npm project** - -```bash -npm init -y -``` - -Expected: `package.json` created - -- [ ] **Step 2: Install core dependencies** - -```bash -npm install astro@^4.0.0 @astrojs/react@^3.0.0 @astrojs/tailwind@^5.0.0 react@^18.2.0 react-dom@^18.2.0 tailwindcss@^3.4.0 -``` - -Expected: Dependencies installed - -- [ ] **Step 3: Install dev dependencies** - -```bash -npm install -D typescript@^5.3.0 @types/react@^18.2.0 @types/react-dom@^18.2.0 prettier@^3.1.0 prettier-plugin-astro@^0.12.0 prettier-plugin-tailwindcss@^0.5.0 -``` - -Expected: Dev dependencies installed - -- [ ] **Step 4: Create Astro config** - -Create `astro.config.mjs`: -```javascript -import { defineConfig } from 'astro/config'; -import react from '@astrojs/react'; -import tailwind from '@astrojs/tailwind'; - -export default defineConfig({ - output: 'static', - integrations: [ - react(), - tailwind() - ] -}); -``` - -- [ ] **Step 5: Create TypeScript config** - -Create `tsconfig.json`: -```json -{ - "extends": "astro/tsconfigs/strict", - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "react", - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, - "resolveJsonModule": true, - "allowJs": true, - "noEmit": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -``` - -- [ ] **Step 6: Create global styles** - -Create `src/styles/global.css` (**Neo-Brutalism** palette + brutalist utilities; Space Grotesk self-hosted same-origin to preserve zero external requests): -```css -@tailwind base; -@tailwind components; -@tailwind utilities; - -@font-face { - font-family: 'Space Grotesk'; - font-style: normal; - font-weight: 400 700; - font-display: swap; - src: url('/fonts/space-grotesk.woff2') format('woff2'); -} - -:root { - --background: 255 253 245; /* Cream */ - --foreground: 10 10 10; - --border: 10 10 10; /* Solid black outlines */ - --muted: 255 255 255; - --muted-foreground: 82 82 82; - --accent: 124 58 237; /* Violet */ - --accent-foreground: 255 255 255; - --shadow: 10 10 10; -} - -.dark { - --background: 10 10 10; - --foreground: 250 250 250; - --border: 250 250 250; /* Light outlines pop on dark */ - --muted: 26 26 26; - --muted-foreground: 163 163 163; - --accent: 167 139 250; - --accent-foreground: 10 10 10; - --shadow: 250 250 250; -} - -body { font-family: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; } - -/* Hard offset shadows (no blur) + mechanical press */ -.shadow-brutal { box-shadow: 4px 4px 0 0 rgb(var(--shadow)); } -.shadow-brutal-sm { box-shadow: 2px 2px 0 0 rgb(var(--shadow)); } -.press-brutal { transition: transform 100ms ease, box-shadow 100ms ease; } -.press-brutal:hover { transform: translate(-2px,-2px); box-shadow: 6px 6px 0 0 rgb(var(--shadow)); } -.press-brutal:active { transform: translate(2px,2px); box-shadow: 0 0 0 0 rgb(var(--shadow)); } -@media (prefers-reduced-motion: reduce) { - .press-brutal, .press-brutal:hover, .press-brutal:active { transition: none; transform: none; } -} -``` - -> **[DESIGN] Neo-Brutalism:** thick outlines, hard offset shadows, sharp corners -> (global `border-radius: 0`), bold uppercase Space Grotesk. All controls use -> `border-2 border-border` + `shadow-brutal` + `press-brutal`. Font is -> **self-hosted** (`public/fonts/space-grotesk.woff2`, preloaded) — never the -> Google Fonts CDN, which would violate the no-egress privacy guarantee. - -- [ ] **Step 7: Create minimal homepage** - -Create `src/pages/index.astro`: -```astro ---- -import '../styles/global.css'; ---- - - - - - - - GoodWebTools - - -

GoodWebTools

-

Privacy-first client-side utilities

- - -``` - -- [ ] **Step 8: Create .gitignore** - -Create `.gitignore`: -``` -node_modules/ -dist/ -.astro/ -.env -.DS_Store -``` - -- [ ] **Step 9: Add dev script to package.json** - -Edit `package.json`, add scripts: -```json -{ - "scripts": { - "dev": "astro dev", - "build": "astro build", - "preview": "astro preview" - } -} -``` - -- [ ] **Step 10: Test dev server** - -Run: `npm run dev` -Expected: Dev server starts at http://localhost:4321, homepage displays - -- [ ] **Step 11: Test build** - -Run: `npm run build` -Expected: Build succeeds, `dist/` directory created - -- [ ] **Step 12: Commit** - -```bash -git add . -git commit -m "feat: initialize Astro project with React and Tailwind" -``` - ---- - -### Task 2: TypeScript Interfaces & Tool Registry Foundation - -**Files:** -- Create: `src/types/tool.ts` -- Create: `src/types/service.ts` -- Create: `src/registry/categories.ts` -- Create: `src/registry/tools.ts` -- Test: `src/registry/tools.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `ToolDef`, `AssetRef`, `Category` types; empty `tools` array export - -- [ ] **Step 1: Write test for ToolDef interface** - -Create `src/registry/tools.test.ts`: -```typescript -import { describe, it, expect } from 'vitest'; -import { tools } from './tools'; -import type { ToolDef } from '@/types/tool'; - -describe('Tool Registry', () => { - it('should export empty tools array initially', () => { - expect(tools).toBeDefined(); - expect(Array.isArray(tools)).toBe(true); - expect(tools.length).toBe(0); - }); - - it('should have valid ToolDef structure when tools are added', () => { - const mockTool: ToolDef = { - id: 'test-tool', - name: 'Test Tool', - category: 'Dev', - route: '/tools/test-tool', - keywords: ['test'], - icon: {} as any, - summary: 'Test tool', - load: () => Promise.resolve({ default: () => null }), - status: 'experimental' - }; - - expect(mockTool.id).toBe('test-tool'); - expect(mockTool.route).toBe('/tools/test-tool'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npm run test` -Expected: FAIL - modules don't exist - -- [ ] **Step 3: Install Vitest** - -```bash -npm install -D vitest@^1.0.0 @testing-library/react@^14.1.0 @testing-library/jest-dom@^6.1.5 jsdom@^23.0.0 -``` - -- [ ] **Step 4: Create Vitest config** - -Create `vitest.config.ts`: -```typescript -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'jsdom', - }, - resolve: { - alias: { - '@': '/src' - } - } -}); -``` - -- [ ] **Step 5: Add test script** - -Edit `package.json`: -```json -{ - "scripts": { - "test": "vitest" - } -} -``` - -- [ ] **Step 6: Create tool types** - -Create `src/types/tool.ts`: -```typescript -import type { LucideIcon } from 'lucide-react'; - -export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media'; - -export interface AssetRef { - url: string; - byteSize: number; - type: 'wasm' | 'model' | 'font' | 'image' | 'other'; - description: string; -} - -export interface ToolDef { - id: string; - name: string; - category: Category; - route: string; - keywords: string[]; - icon: LucideIcon; - summary: string; - load: () => Promise<{ default: React.ComponentType }>; - needsIsolation?: boolean; - assets?: AssetRef[]; - status: 'stable' | 'beta' | 'experimental'; -} -``` - -- [ ] **Step 7: Create categories** - -Create `src/registry/categories.ts`: -```typescript -import type { Category } from '@/types/tool'; - -export const categories: Category[] = [ - 'Dev', - 'PDF', - 'Image', - 'Files', - 'Draw', - 'Media' -]; - -export const categoryColors: Record = { - Dev: 'bg-blue-500', - PDF: 'bg-red-500', - Image: 'bg-green-500', - Files: 'bg-yellow-500', - Draw: 'bg-purple-500', - Media: 'bg-pink-500' -}; -``` - -- [ ] **Step 8: Create tools registry** - -Create `src/registry/tools.ts`: -```typescript -import type { ToolDef } from '@/types/tool'; - -export const tools: ToolDef[] = []; - -export function getToolById(id: string): ToolDef | undefined { - return tools.find(tool => tool.id === id); -} - -export function getToolByRoute(route: string): ToolDef | undefined { - return tools.find(tool => tool.route === route); -} - -export function searchTools(query: string): ToolDef[] { - const lowerQuery = query.toLowerCase(); - - return tools - .map(tool => ({ - tool, - score: calculateScore(tool, lowerQuery) - })) - .filter(({ score }) => score > 0) - .sort((a, b) => b.score - a.score) - .map(({ tool }) => tool); -} - -function calculateScore(tool: ToolDef, query: string): number { - let score = 0; - - if (tool.name.toLowerCase().includes(query)) score += 100; - if (tool.keywords.some(k => k.toLowerCase().includes(query))) score += 50; - if (tool.summary.toLowerCase().includes(query)) score += 30; - if (tool.category.toLowerCase().includes(query)) score += 20; - - return score; -} -``` - -- [ ] **Step 9: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 10: Commit** - -```bash -git add . -git commit -m "feat: add tool registry with TypeScript interfaces and search" -``` - ---- - -### Task 3: Tailwind Configuration & Theme System - -**Files:** -- Create: `tailwind.config.mjs` -- Create: `src/stores/theme.store.ts` -- Test: `src/stores/theme.store.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `themeStore` with `theme` atom, `toggleTheme()`, `initTheme()` functions - -- [ ] **Step 1: Write theme store test** - -Create `src/stores/theme.store.test.ts`: -```typescript -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { get } from 'nanostores'; -import { themeAtom, toggleTheme, initTheme } from './theme.store'; - -describe('Theme Store', () => { - beforeEach(() => { - localStorage.clear(); - document.documentElement.classList.remove('dark'); - }); - - it('should initialize with light theme by default', () => { - initTheme(); - expect(get(themeAtom)).toBe('light'); - }); - - it('should toggle between light and dark', () => { - initTheme(); - toggleTheme(); - expect(get(themeAtom)).toBe('dark'); - expect(document.documentElement.classList.contains('dark')).toBe(true); - - toggleTheme(); - expect(get(themeAtom)).toBe('light'); - expect(document.documentElement.classList.contains('dark')).toBe(false); - }); - - it('should persist theme to localStorage', () => { - initTheme(); - toggleTheme(); - expect(localStorage.getItem('theme')).toBe('dark'); - }); - - it('should load theme from localStorage', () => { - localStorage.setItem('theme', 'dark'); - initTheme(); - expect(get(themeAtom)).toBe('dark'); - }); -}); -``` - -- [ ] **Step 2: Install Nanostores** - -```bash -npm install nanostores@^0.9.5 @nanostores/react@^0.7.1 -``` - -- [ ] **Step 3: Create theme store** - -Create `src/stores/theme.store.ts`: -```typescript -import { atom } from 'nanostores'; - -export type Theme = 'light' | 'dark'; - -export const themeAtom = atom('light'); - -export function initTheme(): void { - const stored = localStorage.getItem('theme') as Theme | null; - const theme = stored || 'light'; - themeAtom.set(theme); - applyTheme(theme); -} - -export function toggleTheme(): void { - const current = themeAtom.get(); - const next: Theme = current === 'light' ? 'dark' : 'light'; - themeAtom.set(next); - applyTheme(next); - localStorage.setItem('theme', next); -} - -function applyTheme(theme: Theme): void { - if (theme === 'dark') { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } -} -``` - -- [ ] **Step 4: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 5: Create Tailwind config** - -Create `tailwind.config.mjs`: -```javascript -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], - darkMode: 'class', - theme: { - extend: { - colors: { - background: 'rgb(var(--background) / )', - foreground: 'rgb(var(--foreground) / )', - border: 'rgb(var(--border) / )', - muted: 'rgb(var(--muted) / )', - 'muted-foreground': 'rgb(var(--muted-foreground) / )', - accent: 'rgb(var(--accent) / )', - 'accent-foreground': 'rgb(var(--accent-foreground) / )', - }, - fontFamily: { sans: ['Space Grotesk', 'ui-sans-serif', 'system-ui', 'sans-serif'] }, - // Neo-Brutalism: sharp corners everywhere (pills only for dots/badges) - borderRadius: { DEFAULT: '0px', sm: '0px', md: '0px', lg: '0px', xl: '0px', '2xl': '0px', full: '9999px' }, - boxShadow: { - brutal: '4px 4px 0 0 rgb(var(--shadow))', - 'brutal-sm': '2px 2px 0 0 rgb(var(--shadow))', - 'brutal-lg': '6px 6px 0 0 rgb(var(--shadow))', - }, - }, - }, - plugins: [], -}; -``` - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "feat: add theme system with Nanostores and Tailwind dark mode" -``` - ---- - -### Task 4: Base Layout with View Transitions - -**Files:** -- Create: `src/layouts/Base.astro` -- Modify: `src/pages/index.astro` -- Create: `src/pages/privacy.astro` - -**Interfaces:** -- Consumes: `global.css`, `themeStore.initTheme()` -- Produces: `Base.astro` layout with ViewTransitions, theme init script - -- [ ] **Step 1: Create Base layout** - -Create `src/layouts/Base.astro`: -```astro ---- -import { ViewTransitions } from 'astro:transitions'; -import '../styles/global.css'; - -export interface Props { - title: string; - description?: string; -} - -const { title, description = 'Privacy-first client-side utilities' } = Astro.props; ---- - - - - - - - - {title} | GoodWebTools - - - - - - - -``` - -- [ ] **Step 2: Update homepage to use layout** - -Modify `src/pages/index.astro`: -```astro ---- -import Base from '@/layouts/Base.astro'; ---- - - -
-

GoodWebTools

-

Privacy-first client-side utilities

-
- -``` - -- [ ] **Step 3: Create privacy page** - -Create `src/pages/privacy.astro`: -```astro ---- -import Base from '@/layouts/Base.astro'; ---- - - -
-

Privacy & Verifiability

- -
-

No Data Leaves Your Device

-

- All processing happens in your browser. No files, images, or documents are ever uploaded to a server. -

-
- -
-

Verify It Yourself

-
    -
  1. Open Developer Tools (F12)
  2. -
  3. Go to the Network tab
  4. -
  5. Use any tool and process a file
  6. -
  7. Watch: zero network requests to external servers
  8. -
-
- -
-

Works Offline

-

- After first use, tools work with your network completely off. This is the strongest proof that nothing leaves your device. -

-
- -
-

Open Source

-

- The code is open source and will be available on GitHub at launch. You can audit it yourself or run it locally. -

-
-
- -``` - -- [ ] **Step 4: Test navigation** - -Run: `npm run dev` -1. Navigate to http://localhost:4321 -2. Click around (add temporary nav links if needed) -3. Check View Transitions animation -Expected: Smooth transitions, theme persists - -- [ ] **Step 5: Commit** - -```bash -git add . -git commit -m "feat: add Base layout with View Transitions and privacy page" -``` - ---- - -### Task 5: FileService Implementation - -**Files:** -- Create: `src/types/service.ts` -- Create: `src/services/file.service.ts` -- Test: `src/services/file.service.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `FileService` class with `getFiles()`, `getFileHandle()`, `createTempFile()`, `cleanupTempFiles()` - -- [ ] **Step 1: Write FileService test** - -Create `src/services/file.service.test.ts`: -```typescript -import { describe, it, expect, beforeEach } from 'vitest'; -import { FileService } from './file.service'; - -describe('FileService', () => { - let fileService: FileService; - - beforeEach(() => { - fileService = new FileService(); - }); - - it('should accept File objects', async () => { - const file = new File(['test'], 'test.txt', { type: 'text/plain' }); - const files = await fileService.getFiles(file); - expect(files).toHaveLength(1); - expect(files[0].name).toBe('test.txt'); - }); - - it('should accept File array', async () => { - const files = [ - new File(['test1'], 'test1.txt'), - new File(['test2'], 'test2.txt') - ]; - const result = await fileService.getFiles(files); - expect(result).toHaveLength(2); - }); - - it('should accept FileList', async () => { - const dt = new DataTransfer(); - dt.items.add(new File(['test'], 'test.txt')); - const fileList = dt.files; - - const result = await fileService.getFiles(fileList); - expect(result).toHaveLength(1); - }); -}); -``` - -- [ ] **Step 2: Create service types** - -Create `src/types/service.ts`: -```typescript -export type FileSource = File | File[] | FileList; -``` - -- [ ] **Step 3: Create FileService** - -Create `src/services/file.service.ts`: -```typescript -import type { FileSource } from '@/types/service'; - -export class FileService { - async getFiles(source: FileSource): Promise { - if (source instanceof File) { - return [source]; - } - - if (Array.isArray(source)) { - return source; - } - - // FileList - return Array.from(source); - } - - async getFileHandle(file: File): Promise { - // File System Access API - may not be available - if (!('showOpenFilePicker' in window)) { - return null; - } - - // If file already has a handle (from showOpenFilePicker), return it - // For now, return null - will be enhanced when needed - return null; - } - - async createTempFile(name: string): Promise { - // OPFS (Origin Private File System) - if (!('storage' in navigator) || !navigator.storage.getDirectory) { - return null; - } - - try { - const root = await navigator.storage.getDirectory(); - const fileHandle = await root.getFileHandle(name, { create: true }); - return fileHandle; - } catch (error) { - console.error('Failed to create temp file:', error); - return null; - } - } - - async cleanupTempFiles(): Promise { - if (!('storage' in navigator) || !navigator.storage.getDirectory) { - return; - } - - try { - const root = await navigator.storage.getDirectory(); - // @ts-expect-error - entries() may not be in types yet - for await (const [name, handle] of root.entries()) { - if (handle.kind === 'file') { - await root.removeEntry(name); - } - } - } catch (error) { - console.error('Failed to cleanup temp files:', error); - } - } -} - -// Singleton instance -export const fileService = new FileService(); -``` - -- [ ] **Step 4: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add . -git commit -m "feat: add FileService for unified file input handling" -``` - ---- - -### Task 6: DownloadService Implementation - -**Files:** -- Create: `src/services/download.service.ts` -- Test: `src/services/download.service.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `DownloadService` class with `download(blob, filename)`, `downloadZip(files, zipName)` - -- [ ] **Step 1: Write DownloadService test** - -Create `src/services/download.service.test.ts`: -```typescript -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { DownloadService } from './download.service'; - -describe('DownloadService', () => { - let downloadService: DownloadService; - let mockLink: HTMLAnchorElement; - - beforeEach(() => { - downloadService = new DownloadService(); - mockLink = document.createElement('a'); - vi.spyOn(document, 'createElement').mockReturnValue(mockLink); - vi.spyOn(mockLink, 'click').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should trigger download with blob URL', async () => { - const blob = new Blob(['test content'], { type: 'text/plain' }); - await downloadService.download(blob, 'test.txt'); - - expect(mockLink.download).toBe('test.txt'); - expect(mockLink.click).toHaveBeenCalled(); - }); - - it('should clean up blob URL after download', async () => { - const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); - const blob = new Blob(['test'], { type: 'text/plain' }); - await downloadService.download(blob, 'test.txt'); - - expect(revokeSpy).toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Create DownloadService** - -Create `src/services/download.service.ts`: -```typescript -export interface BlobFile { - blob: Blob; - filename: string; -} - -export class DownloadService { - async download(blob: Blob, filename: string): Promise { - // Try File System Access API first - if ('showSaveFilePicker' in window) { - try { - const handle = await (window as any).showSaveFilePicker({ - suggestedName: filename, - }); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - return; - } catch (error) { - // User cancelled or API not available, fall through to blob URL - if ((error as Error).name === 'AbortError') { - return; // User cancelled - } - } - } - - // Fallback to blob URL download - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.click(); - - // Clean up - setTimeout(() => URL.revokeObjectURL(url), 100); - } - - async downloadZip(files: BlobFile[], zipName: string): Promise { - // For Phase 0, not implemented yet - // Will be added when zip functionality is needed - throw new Error('Zip download not yet implemented'); - } -} - -// Singleton instance -export const downloadService = new DownloadService(); -``` - -- [ ] **Step 3: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add . -git commit -m "feat: add DownloadService with File System Access API support" -``` - ---- - -### Task 7: WorkerPool & AssetCache Services - -**Files:** -- Create: `src/services/worker.service.ts` -- Create: `src/services/asset.service.ts` -- Test: `src/services/worker.service.test.ts` -- Test: `src/services/asset.service.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `WorkerPool.getWorker()`, `AssetCache.fetch()` with progress callbacks - -- [ ] **Step 1: Install Comlink** - -```bash -npm install comlink@^4.4.1 -``` - -- [ ] **Step 2: Write WorkerPool test** - -Create `src/services/worker.service.test.ts`: -```typescript -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { WorkerPool } from './worker.service'; - -describe('WorkerPool', () => { - let workerPool: WorkerPool; - - beforeEach(() => { - workerPool = new WorkerPool(); - }); - - afterEach(() => { - workerPool.terminateAll(); - }); - - it('should create and cache worker', async () => { - const worker1 = await workerPool.getWorker('test-tool', '/worker.js'); - const worker2 = await workerPool.getWorker('test-tool', '/worker.js'); - - expect(worker1).toBeDefined(); - expect(worker2).toBeDefined(); - }); - - it('should terminate worker by toolId', async () => { - await workerPool.getWorker('test-tool', '/worker.js'); - workerPool.terminateWorker('test-tool'); - - // After termination, getting the worker should create a new one - const newWorker = await workerPool.getWorker('test-tool', '/worker.js'); - expect(newWorker).toBeDefined(); - }); -}); -``` - -- [ ] **Step 3: Create WorkerPool** - -Create `src/services/worker.service.ts`: -```typescript -import { wrap, type Remote } from 'comlink'; - -export class WorkerPool { - private workers = new Map(); - private proxies = new Map>(); - - async getWorker(toolId: string, workerUrl: string): Promise> { - // Return cached proxy if exists - if (this.proxies.has(toolId)) { - return this.proxies.get(toolId)!; - } - - // Create new worker - const worker = new Worker(workerUrl, { type: 'module' }); - const proxy = wrap(worker); - - this.workers.set(toolId, worker); - this.proxies.set(toolId, proxy); - - return proxy; - } - - terminateWorker(toolId: string): void { - const worker = this.workers.get(toolId); - if (worker) { - worker.terminate(); - this.workers.delete(toolId); - this.proxies.delete(toolId); - } - } - - terminateAll(): void { - for (const worker of this.workers.values()) { - worker.terminate(); - } - this.workers.clear(); - this.proxies.clear(); - } -} - -// Singleton instance -export const workerPool = new WorkerPool(); -``` - -- [ ] **Step 4: Write AssetCache test** - -Create `src/services/asset.service.test.ts`: -```typescript -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { AssetCache } from './asset.service'; - -describe('AssetCache', () => { - let assetCache: AssetCache; - - beforeEach(() => { - assetCache = new AssetCache(); - }); - - it('should fetch asset and track progress', async () => { - const mockResponse = { - ok: true, - headers: new Headers({ 'content-length': '1000' }), - body: null, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(1000)) - }; - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - const onProgress = vi.fn(); - await assetCache.fetch('http://example.com/asset.wasm', { onProgress }); - - expect(fetch).toHaveBeenCalled(); - }); - - it('should use cache on second fetch', async () => { - const mockResponse = { - ok: true, - headers: new Headers({ 'content-length': '1000' }), - body: null, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(1000)) - }; - - global.fetch = vi.fn().mockResolvedValue(mockResponse); - - await assetCache.fetch('http://example.com/asset.wasm'); - await assetCache.fetch('http://example.com/asset.wasm'); - - // Should only fetch once (second call uses cache) - // Note: In-memory cache for Phase 0, IndexedDB later - }); -}); -``` - -- [ ] **Step 5: Create AssetCache** - -Create `src/services/asset.service.ts`: -```typescript -export class AssetCache { - private readonly DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days - private readonly PROGRESS_THRESHOLD_BYTES = 1_024_000; // 1 MB - private cache = new Map(); - - async fetch(url: string, options?: { - integrity?: string; - maxAgeMs?: number; - onProgress?: (loadedBytes: number, totalBytes: number) => void; - showProgress?: boolean; - }): Promise { - const maxAgeMs = options?.maxAgeMs || this.DEFAULT_TTL_MS; - - // Check in-memory cache - const cached = this.cache.get(url); - if (cached) { - const ageMs = Date.now() - cached.timestamp; - if (ageMs < maxAgeMs) { - return cached.data; - } - // Expired - remove from cache - this.cache.delete(url); - } - - // Fetch fresh - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status}`); - } - - const totalBytes = parseInt(response.headers.get('content-length') || '0'); - const shouldShowProgress = options?.showProgress ?? (totalBytes > this.PROGRESS_THRESHOLD_BYTES); - - let data: ArrayBuffer; - - if (!shouldShowProgress || !response.body) { - data = await response.arrayBuffer(); - } else { - // Stream with progress - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let loadedBytes = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - chunks.push(value); - loadedBytes += value.length; - options?.onProgress?.(loadedBytes, totalBytes); - } - - // Concatenate chunks - const allData = new Uint8Array(loadedBytes); - let offsetBytes = 0; - for (const chunk of chunks) { - allData.set(chunk, offsetBytes); - offsetBytes += chunk.length; - } - data = allData.buffer; - } - - // TODO: Verify integrity if provided - - // Cache it - this.cache.set(url, { data, timestamp: Date.now() }); - return data; - } - - async isCached(url: string): Promise { - return this.cache.has(url); - } -} - -// Singleton instance -export const assetCache = new AssetCache(); -``` - -- [ ] **Step 6: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add . -git commit -m "feat: add WorkerPool and AssetCache services with progress tracking" -``` - ---- - -### Task 8: ProgressService & PersistenceService - -**Files:** -- Create: `src/services/progress.service.ts` -- Create: `src/services/persistence.service.ts` -- Create: `src/stores/worker.store.ts` -- Test: `src/services/progress.service.test.ts` -- Test: `src/services/persistence.service.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `ProgressService` with toast/progress methods, `PersistenceService` with auto-save and navigation guards - -- [ ] **Step 1: Create worker status store** - -Create `src/stores/worker.store.ts`: -```typescript -import { atom, map } from 'nanostores'; - -export interface ProgressState { - id: string; - label: string; - percent: number; -} - -export const progressMap = map>({}); - -export function setProgress(id: string, label: string, percent: number): void { - progressMap.setKey(id, { id, label, percent }); -} - -export function removeProgress(id: string): void { - const current = progressMap.get(); - const { [id]: removed, ...rest } = current; - progressMap.set(rest); -} -``` - -- [ ] **Step 2: Write ProgressService test** - -Create `src/services/progress.service.test.ts`: -```typescript -import { describe, it, expect, vi } from 'vitest'; -import { ProgressService } from './progress.service'; -import { get } from 'nanostores'; -import { progressMap } from '@/stores/worker.store'; - -describe('ProgressService', () => { - let progressService: ProgressService; - - beforeEach(() => { - progressService = new ProgressService(); - progressMap.set({}); - }); - - it('should start progress', () => { - progressService.startProgress('test-id', 'Processing...'); - const state = get(progressMap); - expect(state['test-id']).toBeDefined(); - expect(state['test-id'].label).toBe('Processing...'); - expect(state['test-id'].percent).toBe(0); - }); - - it('should update progress', () => { - progressService.startProgress('test-id', 'Processing...'); - progressService.updateProgress('test-id', 50); - const state = get(progressMap); - expect(state['test-id'].percent).toBe(50); - }); - - it('should complete progress', () => { - progressService.startProgress('test-id', 'Processing...'); - progressService.completeProgress('test-id'); - const state = get(progressMap); - expect(state['test-id']).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 3: Create ProgressService** - -Create `src/services/progress.service.ts`: -```typescript -import { setProgress, removeProgress } from '@/stores/worker.store'; - -export class ProgressService { - startProgress(id: string, label: string): void { - setProgress(id, label, 0); - } - - updateProgress(id: string, percent: number): void { - const current = progressMap.get()[id]; - if (current) { - setProgress(id, current.label, percent); - } - } - - completeProgress(id: string): void { - removeProgress(id); - } - - toast(message: string, type: 'success' | 'error' | 'info' = 'info'): void { - // Simple console log for Phase 0, will add toast UI later - console.log(`[${type.toUpperCase()}]`, message); - - // TODO: Add visual toast component - } -} - -// Singleton instance -export const progressService = new ProgressService(); - -// Re-export for convenience -import { progressMap } from '@/stores/worker.store'; -export { progressMap }; -``` - -- [ ] **Step 4: Write PersistenceService test** - -Create `src/services/persistence.service.test.ts`: -```typescript -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { PersistenceService } from './persistence.service'; - -describe('PersistenceService', () => { - let persistenceService: PersistenceService; - - beforeEach(() => { - persistenceService = new PersistenceService(); - localStorage.clear(); - }); - - it('should mark and check dirty state', () => { - persistenceService.markDirty('test-tool'); - expect(persistenceService.isDirty('test-tool')).toBe(true); - }); - - it('should clear dirty state', () => { - persistenceService.markDirty('test-tool'); - persistenceService.markClean('test-tool'); - expect(persistenceService.isDirty('test-tool')).toBe(false); - }); - - it('should auto-save to localStorage', async () => { - const data = { content: 'test data' }; - await persistenceService.autoSave('test-tool', data); - - const stored = localStorage.getItem('gwt-autosave-test-tool'); - expect(stored).toBeTruthy(); - expect(JSON.parse(stored!)).toEqual(data); - }); - - it('should load auto-save data', async () => { - const data = { content: 'test data' }; - localStorage.setItem('gwt-autosave-test-tool', JSON.stringify(data)); - - const loaded = await persistenceService.loadAutoSave('test-tool'); - expect(loaded).toEqual(data); - }); -}); -``` - -- [ ] **Step 5: Create PersistenceService** - -Create `src/services/persistence.service.ts`: -```typescript -export class PersistenceService { - private dirtyTools = new Set(); - private navigationGuards = new Set(); - - async autoSave(toolId: string, data: any): Promise { - const key = `gwt-autosave-${toolId}`; - localStorage.setItem(key, JSON.stringify(data)); - } - - async loadAutoSave(toolId: string): Promise { - const key = `gwt-autosave-${toolId}`; - const stored = localStorage.getItem(key); - return stored ? JSON.parse(stored) : null; - } - - async clearAutoSave(toolId: string): Promise { - const key = `gwt-autosave-${toolId}`; - localStorage.removeItem(key); - } - - async saveToFile( - data: Blob, - suggestedName: string, - fileHandle?: FileSystemFileHandle - ): Promise { - if (!('showSaveFilePicker' in window)) { - return null; - } - - try { - const handle = fileHandle || await (window as any).showSaveFilePicker({ - suggestedName, - }); - const writable = await handle.createWritable(); - await writable.write(data); - await writable.close(); - return handle; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return null; // User cancelled - } - throw error; - } - } - - async loadFromFile(accept?: string[]): Promise<{ data: ArrayBuffer; handle: FileSystemFileHandle } | null> { - if (!('showOpenFilePicker' in window)) { - return null; - } - - try { - const [handle] = await (window as any).showOpenFilePicker({ - types: accept ? [{ - accept: { 'application/json': accept } - }] : undefined - }); - const file = await handle.getFile(); - const data = await file.arrayBuffer(); - return { data, handle }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return null; // User cancelled - } - throw error; - } - } - - markDirty(toolId: string): void { - this.dirtyTools.add(toolId); - } - - markClean(toolId: string): void { - this.dirtyTools.delete(toolId); - } - - isDirty(toolId: string): boolean { - return this.dirtyTools.has(toolId); - } - - enableNavigationGuard(toolId: string): void { - this.navigationGuards.add(toolId); - - // Add beforeunload listener - window.addEventListener('beforeunload', this.handleBeforeUnload); - } - - disableNavigationGuard(toolId: string): void { - this.navigationGuards.delete(toolId); - - // Remove listener if no guards active - if (this.navigationGuards.size === 0) { - window.removeEventListener('beforeunload', this.handleBeforeUnload); - } - } - - private handleBeforeUnload = (e: BeforeUnloadEvent): string | undefined => { - for (const toolId of this.navigationGuards) { - if (this.isDirty(toolId)) { - e.preventDefault(); - return ''; // Modern browsers show generic message - } - } - return undefined; - }; -} - -// Singleton instance -export const persistenceService = new PersistenceService(); -``` - -- [ ] **Step 6: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add . -git commit -m "feat: add ProgressService and PersistenceService with auto-save" -``` - ---- - -### Task 9: React Hooks (useWorker & usePersistence) - -**Files:** -- Create: `src/hooks/useWorker.ts` -- Create: `src/hooks/usePersistence.ts` -- Test: `src/hooks/useWorker.test.tsx` -- Test: `src/hooks/usePersistence.test.tsx` - -**Interfaces:** -- Consumes: `workerPool`, `persistenceService` -- Produces: `useWorker(toolId, workerUrl)` hook, `usePersistence(toolId)` hook - -- [ ] **Step 1: Write useWorker test** - -Create `src/hooks/useWorker.test.tsx`: -```typescript -import { describe, it, expect, vi } from 'vitest'; -import { renderHook } from '@testing-library/react'; -import { useWorker } from './useWorker'; - -describe('useWorker', () => { - it('should return worker proxy', () => { - const workerUrl = new URL('./test.worker.ts', import.meta.url); - const { result } = renderHook(() => useWorker('test-tool', workerUrl)); - - expect(result.current).toBeDefined(); - }); - - it('should cleanup on unmount', () => { - const workerUrl = new URL('./test.worker.ts', import.meta.url); - const { unmount } = renderHook(() => useWorker('test-tool', workerUrl)); - - unmount(); - // Worker should be terminated (tested via integration) - }); -}); -``` - -- [ ] **Step 2: Create useWorker hook** - -Create `src/hooks/useWorker.ts`: -```typescript -import { useEffect, useRef } from 'react'; -import type { Remote } from 'comlink'; -import { workerPool } from '@/services/worker.service'; - -export function useWorker(toolId: string, workerUrl: URL): Remote | null { - const workerRef = useRef | null>(null); - - useEffect(() => { - let mounted = true; - - workerPool.getWorker(toolId, workerUrl.href).then(proxy => { - if (mounted) { - workerRef.current = proxy; - } - }); - - return () => { - mounted = false; - workerPool.terminateWorker(toolId); - }; - }, [toolId, workerUrl.href]); - - return workerRef.current; -} -``` - -- [ ] **Step 3: Write usePersistence test** - -Create `src/hooks/usePersistence.test.tsx`: -```typescript -import { describe, it, expect, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { usePersistence } from './usePersistence'; - -describe('usePersistence', () => { - beforeEach(() => { - localStorage.clear(); - }); - - it('should provide persistence methods', () => { - const { result } = renderHook(() => usePersistence('test-tool')); - - expect(result.current.autoSave).toBeDefined(); - expect(result.current.loadAutoSave).toBeDefined(); - expect(result.current.markDirty).toBeDefined(); - expect(result.current.markClean).toBeDefined(); - expect(result.current.isDirty).toBeDefined(); - }); - - it('should track dirty state', () => { - const { result } = renderHook(() => usePersistence('test-tool')); - - act(() => { - result.current.markDirty(); - }); - - expect(result.current.isDirty()).toBe(true); - }); - - it('should enable navigation guard on mount', () => { - const { unmount } = renderHook(() => usePersistence('test-tool')); - - // Navigation guard should be enabled - unmount(); - // Navigation guard should be disabled - }); -}); -``` - -- [ ] **Step 4: Create usePersistence hook** - -Create `src/hooks/usePersistence.ts`: -```typescript -import { useEffect, useCallback } from 'react'; -import { persistenceService } from '@/services/persistence.service'; - -export function usePersistence(toolId: string) { - useEffect(() => { - persistenceService.enableNavigationGuard(toolId); - - return () => { - persistenceService.disableNavigationGuard(toolId); - }; - }, [toolId]); - - const autoSave = useCallback( - (data: any) => persistenceService.autoSave(toolId, data), - [toolId] - ); - - const loadAutoSave = useCallback( - () => persistenceService.loadAutoSave(toolId), - [toolId] - ); - - const clearAutoSave = useCallback( - () => persistenceService.clearAutoSave(toolId), - [toolId] - ); - - const markDirty = useCallback( - () => persistenceService.markDirty(toolId), - [toolId] - ); - - const markClean = useCallback( - () => persistenceService.markClean(toolId), - [toolId] - ); - - const isDirty = useCallback( - () => persistenceService.isDirty(toolId), - [toolId] - ); - - const saveToFile = useCallback( - (data: Blob, suggestedName: string, fileHandle?: FileSystemFileHandle) => - persistenceService.saveToFile(data, suggestedName, fileHandle), - [] - ); - - const loadFromFile = useCallback( - (accept?: string[]) => persistenceService.loadFromFile(accept), - [] - ); - - return { - autoSave, - loadAutoSave, - clearAutoSave, - markDirty, - markClean, - isDirty, - saveToFile, - loadFromFile, - }; -} -``` - -- [ ] **Step 5: Run tests** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "feat: add useWorker and usePersistence React hooks" -``` - ---- - ---- - -### Task 10: UI Components (Dropzone, ProgressBar, FileList, ResultActions) - -**Files:** -- Create: `src/components/ui/Dropzone.tsx` -- Create: `src/components/ui/ProgressBar.tsx` -- Create: `src/components/ui/FileList.tsx` -- Create: `src/components/ui/ResultActions.tsx` - -**Interfaces:** -- Consumes: None -- Produces: Reusable UI components for all tools - -- [ ] **Step 1: Create Dropzone component** - -Create `src/components/ui/Dropzone.tsx`: -```typescript -import { useCallback, useState } from 'react'; - -export interface DropzoneProps { - onDrop: (files: File[]) => void | Promise; - accept?: string; - multiple?: boolean; - children?: React.ReactNode; -} - -export function Dropzone({ onDrop, accept, multiple = true, children }: DropzoneProps) { - const [isDragging, setIsDragging] = useState(false); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - - const files = Array.from(e.dataTransfer.files); - await onDrop(files); - }, [onDrop]); - - const handleFileInput = useCallback(async (e: React.ChangeEvent) => { - const files = e.target.files ? Array.from(e.target.files) : []; - await onDrop(files); - }, [onDrop]); - - return ( -
- - -
- ); -} -``` - -- [ ] **Step 2: Create ProgressBar component** - -Create `src/components/ui/ProgressBar.tsx`: -```typescript -export interface ProgressBarProps { - percent: number; - label?: string; -} - -export function ProgressBar({ percent, label }: ProgressBarProps) { - const clampedPercent = Math.min(Math.max(percent, 0), 100); - - return ( -
- {label && ( -
- {label} - {clampedPercent.toFixed(0)}% -
- )} -
-
-
-
- ); -} -``` - -- [ ] **Step 3: Create FileList component** - -Create `src/components/ui/FileList.tsx`: -```typescript -export interface FileListProps { - files: File[]; - onRemove?: (index: number) => void; -} - -export function FileList({ files, onRemove }: FileListProps) { - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }; - - return ( -
- {files.map((file, index) => ( -
-
-

{file.name}

-

- {formatFileSize(file.size)} -

-
- {onRemove && ( - - )} -
- ))} -
- ); -} -``` - -- [ ] **Step 4: Create ResultActions component** - -Create `src/components/ui/ResultActions.tsx`: -```typescript -import { downloadService } from '@/services/download.service'; - -export interface ResultActionsProps { - blob: Blob | null; - filename: string; - disabled?: boolean; -} - -export function ResultActions({ blob, filename, disabled }: ResultActionsProps) { - const handleDownload = async () => { - if (!blob) return; - await downloadService.download(blob, filename); - }; - - const handleCopy = async () => { - if (!blob) return; - - try { - const text = await blob.text(); - await navigator.clipboard.writeText(text); - // TODO: Show toast notification - console.log('Copied to clipboard'); - } catch (error) { - console.error('Failed to copy:', error); - } - }; - - return ( -
- - -
- ); -} -``` - -- [ ] **Step 5: Test components** - -Run: `npm run dev` -1. Create a test page importing these components -2. Verify drag-drop works -3. Verify progress bar animates -4. Verify file list displays -5. Verify download works - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "feat: add reusable UI components (Dropzone, ProgressBar, FileList, ResultActions)" -``` - ---- - -### Task 11: Shell Components (ShellIsland, ThemeToggle) - -**Files:** -- Create: `src/components/shell/ThemeToggle.tsx` -- Create: `src/components/shell/ShellIsland.tsx` -- Modify: `src/layouts/Base.astro` - -**Interfaces:** -- Consumes: `themeStore`, `tools` registry -- Produces: Persisted shell with theme toggle, placeholder for command palette - -- [ ] **Step 1: Install lucide-react** - -```bash -npm install lucide-react@^0.294.0 -``` - -- [ ] **Step 2: Create ThemeToggle component** - -Create `src/components/shell/ThemeToggle.tsx`: -```typescript -import { useStore } from '@nanostores/react'; -import { Moon, Sun } from 'lucide-react'; -import { themeAtom, toggleTheme } from '@/stores/theme.store'; - -export function ThemeToggle() { - const theme = useStore(themeAtom); - - return ( - - ); -} -``` - -- [ ] **Step 3: Create ShellIsland component** - -Create `src/components/shell/ShellIsland.tsx`: -```typescript -import { useEffect } from 'react'; -import { ThemeToggle } from './ThemeToggle'; -import { initTheme } from '@/stores/theme.store'; - -export function ShellIsland() { - useEffect(() => { - initTheme(); - }, []); - - return ( -
-
-
- {/* Logo */} - - GoodWebTools - - - {/* Actions */} -
- - -
-
-
-
- ); -} -``` - -- [ ] **Step 4: Update Base layout to include ShellIsland** - -Modify `src/layouts/Base.astro`: -```astro ---- -import { ViewTransitions } from 'astro:transitions'; -import { ShellIsland } from '@/components/shell/ShellIsland'; -import '../styles/global.css'; - -export interface Props { - title: string; - description?: string; -} - -const { title, description = 'Privacy-first client-side utilities' } = Astro.props; ---- - - - - - - - - {title} | GoodWebTools - - - - - - - - -``` - -- [ ] **Step 5: Test shell persistence** - -Run: `npm run dev` -1. Navigate between pages -2. Toggle theme -3. Verify theme persists across navigation -4. Verify shell doesn't re-mount (check console) - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "feat: add persisted shell with theme toggle and navigation" -``` - ---- - -### Task 12: Command Palette with Search - -**Files:** -- Create: `src/components/shell/CommandPalette.tsx` -- Modify: `src/components/shell/ShellIsland.tsx` -- Modify: `src/registry/tools.ts` (add demo tool) - -**Interfaces:** -- Consumes: `tools` registry, `searchTools()` function -- Produces: Working cmdk command palette with fuzzy search - -- [ ] **Step 1: Install cmdk** - -```bash -npm install cmdk@^0.2.0 -``` - -- [ ] **Step 2: Add Hash demo tool to registry** - -Modify `src/registry/tools.ts`: -```typescript -import { Hash } from 'lucide-react'; -import type { ToolDef } from '@/types/tool'; - -export const tools: ToolDef[] = [ - { - id: 'hash-demo', - name: 'Hash File', - category: 'Dev', - route: '/tools/hash-demo', - keywords: ['hash', 'sha256', 'checksum', 'demo', 'validation'], - icon: Hash, - summary: 'Generate SHA-256 hash (validation demo)', - load: () => import('@/islands/demo/HashDemo'), - status: 'experimental' - } -]; - -// ... rest of existing functions -``` - -- [ ] **Step 3: Create CommandPalette component** - -Create `src/components/shell/CommandPalette.tsx`: -```typescript -import { useState, useEffect } from 'react'; -import { Command } from 'cmdk'; -import { useStore } from '@nanostores/react'; -import { searchTools } from '@/registry/tools'; -import { categories } from '@/registry/categories'; - -export function CommandPalette() { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); - const results = searchTools(search); - - useEffect(() => { - const down = (e: KeyboardEvent) => { - if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen(prev => !prev); - } - }; - - document.addEventListener('keydown', down); - return () => document.removeEventListener('keydown', down); - }, []); - - if (!open) return null; - - return ( -
setOpen(false)}> -
- e.stopPropagation()} - > - - - - No tools found. - - - {categories.map(category => { - const categoryTools = results.filter(t => t.category === category); - if (categoryTools.length === 0) return null; - - return ( - - {categoryTools.map(tool => ( - { - window.location.href = tool.route; - }} - className="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer hover:bg-muted data-[selected]:bg-muted" - > - -
-

{tool.name}

-

{tool.summary}

-
- - {tool.status} - -
- ))} -
- ); - })} -
-
-
-
- ); -} -``` - -- [ ] **Step 4: Add CommandPalette to ShellIsland** - -Modify `src/components/shell/ShellIsland.tsx`: -```typescript -import { useEffect } from 'react'; -import { ThemeToggle } from './ThemeToggle'; -import { CommandPalette } from './CommandPalette'; -import { initTheme } from '@/stores/theme.store'; - -export function ShellIsland() { - useEffect(() => { - initTheme(); - }, []); - - return ( - <> -
-
-
- - GoodWebTools - - -
- - -
-
-
-
- - - - ); -} -``` - -- [ ] **Step 5: Test command palette** - -Run: `npm run dev` -1. Press ⌘K (or Ctrl+K) -2. Palette opens -3. Type "hash" -4. See Hash File tool -5. Press Enter or click -Expected: Navigate to /tools/hash-demo (404 for now - will be created next) - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "feat: add command palette with fuzzy search using cmdk" -``` - ---- - -### Task 13: Dynamic Tool Route - -**Files:** -- Create: `src/pages/tools/[tool].astro` - -**Interfaces:** -- Consumes: `tools` registry, `getToolByRoute()` -- Produces: Dynamic route that loads tool islands - -- [ ] **Step 1: Create dynamic tool route** - -Create `src/pages/tools/[tool].astro`: -```astro ---- -import Base from '@/layouts/Base.astro'; -import { getToolByRoute, tools } from '@/registry/tools'; - -export async function getStaticPaths() { - return tools.map(tool => ({ - params: { tool: tool.id }, - props: { tool } - })); -} - -const { tool } = Astro.props; -const ToolComponent = (await tool.load()).default; ---- - - -
-
-

{tool.name}

-

{tool.summary}

- {tool.status === 'experimental' && ( - - Experimental - - )} -
- - -
- -``` - -- [ ] **Step 2: Test route** - -Run: `npm run build` -Expected: Build succeeds (will fail to load HashDemo component - that's next) - -- [ ] **Step 3: Commit** - -```bash -git add . -git commit -m "feat: add dynamic tool route with island loading" -``` - ---- - -### Task 14: Hash Demo Tool (Island + Worker) - -**Files:** -- Create: `src/islands/demo/HashDemo.tsx` -- Create: `src/tools/demo/hash.lib.ts` -- Create: `src/tools/demo/hash.worker.ts` -- Test: `src/tools/demo/hash.lib.test.ts` - -**Interfaces:** -- Consumes: `useWorker` hook, `Dropzone`, `ProgressBar`, `ResultActions` components -- Produces: Working hash demo tool validating full pipeline - -- [ ] **Step 1: Write hash lib test** - -Create `src/tools/demo/hash.lib.test.ts`: -```typescript -import { describe, it, expect } from 'vitest'; -import { hashToHex } from './hash.lib'; - -describe('Hash Library', () => { - it('should convert hash buffer to hex string', () => { - const buffer = new Uint8Array([0, 15, 255, 128]); - const hex = hashToHex(buffer); - expect(hex).toBe('000fff80'); - }); - - it('should handle empty buffer', () => { - const buffer = new Uint8Array([]); - const hex = hashToHex(buffer); - expect(hex).toBe(''); - }); -}); -``` - -- [ ] **Step 2: Create hash library** - -Create `src/tools/demo/hash.lib.ts`: -```typescript -export function hashToHex(buffer: Uint8Array): string { - return Array.from(buffer) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); -} - -export async function hashFile(fileBuffer: ArrayBuffer): Promise { - const hashBuffer = await crypto.subtle.digest('SHA-256', fileBuffer); - const hashArray = new Uint8Array(hashBuffer); - return hashToHex(hashArray); -} -``` - -- [ ] **Step 3: Run hash lib test** - -Run: `npm run test` -Expected: PASS - -- [ ] **Step 4: Create hash worker** - -Create `src/tools/demo/hash.worker.ts`: -```typescript -import { expose } from 'comlink'; -import { hashFile } from './hash.lib'; - -const api = { - async hashFile( - fileBuffer: ArrayBuffer, - onProgress: (percent: number) => void - ): Promise { - onProgress(50); - const hash = await hashFile(fileBuffer); - onProgress(100); - return hash; - } -}; - -export type HashWorkerAPI = typeof api; -expose(api); -``` - -- [ ] **Step 5: Create HashDemo island** - -Create `src/islands/demo/HashDemo.tsx`: -```typescript -import { useState } from 'react'; -import { proxy } from 'comlink'; -import { useWorker } from '@/hooks/useWorker'; -import { Dropzone } from '@/components/ui/Dropzone'; -import { ProgressBar } from '@/components/ui/ProgressBar'; -import { ResultActions } from '@/components/ui/ResultActions'; -import type { HashWorkerAPI } from '@/tools/demo/hash.worker'; - -export default function HashDemo() { - const [hash, setHash] = useState(''); - const [progress, setProgress] = useState(0); - const [fileName, setFileName] = useState(''); - const [processing, setProcessing] = useState(false); - - const worker = useWorker( - 'hash-demo', - new URL('@/tools/demo/hash.worker.ts', import.meta.url) - ); - - const handleFile = async (files: File[]) => { - if (files.length === 0 || !worker) return; - - const file = files[0]; - setFileName(file.name); - setProcessing(true); - setProgress(0); - - try { - const buffer = await file.arrayBuffer(); - const result = await worker.hashFile( - buffer, - proxy((pct) => setProgress(pct)) - ); - setHash(result); - } catch (error) { - console.error('Hash failed:', error); - } finally { - setProcessing(false); - } - }; - - const resultBlob = hash - ? new Blob([`${hash} ${fileName}\n`], { type: 'text/plain' }) - : null; - - return ( -
- -
-

Drop file here or click to browse

-

- Generate SHA-256 hash -

-
-
- - {processing && } - - {hash && ( -
-
-

SHA-256 Hash

- {hash} -
- - -
- )} -
- ); -} -``` - -- [ ] **Step 6: Test HashDemo tool** - -Run: `npm run dev` -1. Navigate to http://localhost:4321 -2. Press ⌘K, search "hash", click Hash File -3. Drop a file -4. See progress bar -5. See hash result -6. Download .sha256 file -Expected: All working, hash is correct - -- [ ] **Step 7: Verify worker terminates** - -1. Navigate away from hash tool -2. Check DevTools console -3. No worker errors -Expected: Worker terminates cleanly - -- [ ] **Step 8: Commit** - -```bash -git add . -git commit -m "feat: add Hash File demo tool with worker pipeline" -``` - ---- - -### Task 15: PWA Configuration - -**Files:** -- Install: `@vite-pwa/astro` -- Modify: `astro.config.mjs` -- Create: `public/manifest.json` -- Create: `public/icon-192.png`, `public/icon-512.png` - -**Interfaces:** -- Consumes: Astro config -- Produces: Working PWA with service worker and offline capability - -- [ ] **Step 1: Install PWA plugin** - -```bash -npm install @vite-pwa/astro@^0.2.0 -``` - -- [ ] **Step 2: Create PWA manifest** - -Create `public/manifest.json`: -```json -{ - "name": "GoodWebTools", - "short_name": "GWT", - "description": "Privacy-first client-side utilities", - "theme_color": "#2563eb", - "background_color": "#ffffff", - "display": "standalone", - "start_url": "/", - "scope": "/", - "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} -``` - -- [ ] **Step 3: Create placeholder icons** - -For Phase 0, create simple colored squares as placeholders: -1. Create 192x192px blue square, save as `public/icon-192.png` -2. Create 512x512px blue square, save as `public/icon-512.png` - -(In production, replace with proper logo) - -- [ ] **Step 4: Update Astro config with PWA** - -Modify `astro.config.mjs`: -```javascript -import { defineConfig } from 'astro/config'; -import react from '@astrojs/react'; -import tailwind from '@astrojs/tailwind'; -import { VitePWA } from '@vite-pwa/astro'; - -export default defineConfig({ - output: 'static', - integrations: [ - react(), - tailwind(), - VitePWA({ - registerType: 'autoUpdate', - manifest: { - name: 'GoodWebTools', - short_name: 'GWT', - description: 'Privacy-first client-side utilities', - theme_color: '#2563eb', - background_color: '#ffffff', - display: 'standalone', - icons: [ - { - src: '/icon-192.png', - sizes: '192x192', - type: 'image/png' - }, - { - src: '/icon-512.png', - sizes: '512x512', - type: 'image/png' - } - ] - }, - workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], - cleanupOutdatedCaches: true, - runtimeCaching: [ - { - urlPattern: /\.wasm$/, - handler: 'NetworkFirst', - options: { - cacheName: 'wasm-cache', - networkTimeoutSeconds: 5, - expiration: { - maxEntries: 20, - maxAgeSeconds: 30 * 24 * 60 * 60, - purgeOnQuotaError: true - } - } - }, - { - urlPattern: /\/islands\/.+\.js$/, - handler: 'StaleWhileRevalidate', - options: { - cacheName: 'tool-chunks', - expiration: { - maxEntries: 50, - maxAgeSeconds: 7 * 24 * 60 * 60, - purgeOnQuotaError: true - } - } - } - ] - } - }) - ], - vite: { - build: { - rollupOptions: { - output: { - manualChunks: { - 'react-vendor': ['react', 'react-dom'], - 'worker-vendor': ['comlink'], - 'ui-vendor': ['cmdk', 'lucide-react'] - } - } - } - }, - worker: { - format: 'es' - } - } -}); -``` - -- [ ] **Step 5: Test PWA** - -Run: `npm run build && npm run preview` -1. Open in Chrome -2. Check Application tab in DevTools -3. Verify service worker registered -4. Verify manifest loaded -5. Try "Install app" prompt -Expected: PWA installs, works offline after first visit - -- [ ] **Step 6: Test offline** - -1. With app installed and visited -2. Disconnect network -3. Reload app -4. Try Hash tool -Expected: Still works offline - -- [ ] **Step 7: Commit** - -```bash -git add . -git commit -m "feat: add PWA support with offline capability" -``` - ---- - -### Task 16: Cloudflare Pages Deployment - -**Files:** -- Create: `_headers` -- Create: `.github/workflows/deploy.yml` - -**Interfaces:** -- Consumes: Build output -- Produces: Deployed site on Cloudflare Pages - -- [ ] **Step 1: Create Cloudflare headers** - -Create `_headers`: -``` -/* - X-Frame-Options: DENY - X-Content-Type-Options: nosniff - Referrer-Policy: strict-origin-when-cross-origin - Permissions-Policy: camera=(), microphone=(), geolocation=() - Cross-Origin-Embedder-Policy: require-corp - Cross-Origin-Opener-Policy: same-origin - Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; - -/wasm/* - Cache-Control: public, max-age=31536000, immutable - Cross-Origin-Resource-Policy: same-origin - -/models/* - Cache-Control: public, max-age=2592000 - Cross-Origin-Resource-Policy: same-origin -``` - -- [ ] **Step 2: Create GitHub Actions workflow** - -Create `.github/workflows/deploy.yml`: -```yaml -name: Deploy to Cloudflare Pages - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - contents: read - deployments: write - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm run test - - - name: Build - run: npm run build - - - name: Deploy to Cloudflare Pages - uses: cloudflare/pages-action@v1 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - projectName: goodwebtools - directory: dist - gitHubToken: ${{ secrets.GITHUB_TOKEN }} -``` - -- [ ] **Step 3: Test build locally** - -Run: `npm run build` -Expected: Build succeeds, `dist/` contains: -- `_headers` file -- HTML files -- Assets with hashes -- PWA manifest and service worker - -- [ ] **Step 4: Verify headers in build** - -Run: `ls -la dist/_headers` -Expected: File exists - -- [ ] **Step 5: Document Cloudflare setup** - -Add to README.md (will create in next task): -```markdown -## Deployment - -### Cloudflare Pages - -1. Create Cloudflare Pages project -2. Connect to GitHub repository -3. Set build command: `npm run build` -4. Set build output: `dist` -5. Add secrets: - - `CLOUDFLARE_API_TOKEN` - - `CLOUDFLARE_ACCOUNT_ID` -``` - -- [ ] **Step 6: Commit** - -```bash -git add . -git commit -m "feat: add Cloudflare Pages deployment with headers and CI/CD" -``` - ---- - -### Task 17: Development Environment (ESLint, Prettier, VS Code) - -**Files:** -- Create: `.eslintrc.js` -- Create: `.prettierrc` -- Create: `.vscode/settings.json` -- Create: `.vscode/extensions.json` - -**Interfaces:** -- Consumes: TypeScript config -- Produces: Configured dev environment with linting and formatting - -- [ ] **Step 1: Install ESLint dependencies** - -```bash -npm install -D eslint@^8.55.0 @typescript-eslint/parser@^6.15.0 @typescript-eslint/eslint-plugin@^6.15.0 eslint-plugin-react@^7.33.2 eslint-plugin-react-hooks@^4.6.0 eslint-plugin-astro@^0.31.0 -``` - -- [ ] **Step 2: Create ESLint config** - -Create `.eslintrc.js`: -```javascript -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - ecmaFeatures: { - jsx: true - } - }, - env: { - browser: true, - es2022: true, - node: true - }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - ], - plugins: ['@typescript-eslint', 'react', 'react-hooks'], - settings: { - react: { - version: 'detect' - } - }, - rules: { - 'no-console': ['warn', { allow: ['warn', 'error'] }], - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_' - }], - '@typescript-eslint/no-explicit-any': 'warn', - 'react/react-in-jsx-scope': 'off', - 'react/prop-types': 'off' - }, - overrides: [ - { - files: ['*.astro'], - parser: 'astro-eslint-parser', - parserOptions: { - parser: '@typescript-eslint/parser', - extraFileExtensions: ['.astro'] - }, - extends: ['plugin:astro/recommended'] - } - ] -}; -``` - -- [ ] **Step 3: Update Prettier config** - -Modify `.prettierrc`: -```json -{ - "semi": true, - "singleQuote": true, - "trailingComma": "es5", - "printWidth": 100, - "tabWidth": 2, - "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.astro", - "options": { - "parser": "astro" - } - } - ] -} -``` - -- [ ] **Step 4: Create VS Code settings** - -Create `.vscode/settings.json`: -```json -{ - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "tailwindCSS.experimental.classRegex": [ - ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ], - "files.associations": { - "*.astro": "astro" - }, - "[astro]": { - "editor.defaultFormatter": "astro-build.astro-vscode" - }, - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true -} -``` - -- [ ] **Step 5: Create VS Code extensions** - -Create `.vscode/extensions.json`: -```json -{ - "recommendations": [ - "astro-build.astro-vscode", - "bradlc.vscode-tailwindcss", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "ms-vscode.vscode-typescript-next" - ] -} -``` - -- [ ] **Step 6: Add lint scripts to package.json** - -Edit `package.json`: -```json -{ - "scripts": { - "lint": "eslint src --ext .ts,.tsx,.astro", - "lint:fix": "eslint src --ext .ts,.tsx,.astro --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,astro,css}\"" - } -} -``` - -- [ ] **Step 7: Run linter** - -Run: `npm run lint` -Expected: No errors (or fix any found) - -- [ ] **Step 8: Run formatter** - -Run: `npm run format` -Expected: All files formatted - -- [ ] **Step 9: Commit** - -```bash -git add . -git commit -m "feat: add ESLint, Prettier, and VS Code configuration" -``` - ---- - -### Task 18: Documentation - -**Files:** -- Create: `README.md` -- Create: `CONTRIBUTING.md` -- Create: `docs/architecture.md` - -**Interfaces:** -- Consumes: Project knowledge -- Produces: Complete documentation - -- [ ] **Step 1: Create README** - -Create `README.md`: -```markdown -# GoodWebTools - -Privacy-first client-side utilities. All processing happens in your browser. - -## Features - -- **100% Client-Side** - No file uploads, no servers -- **Works Offline** - Install as PWA -- **Open Source** - Audit the code yourself -- **Privacy-First** - Verify with DevTools Network tab - -## Development - -### Prerequisites - -- Node.js 20+ -- npm 10+ - -### Setup - -\`\`\`bash -npm install -npm run dev -\`\`\` - -Open http://localhost:4321 - -### Scripts - -- `npm run dev` - Start dev server -- `npm run build` - Build for production -- `npm run preview` - Preview production build -- `npm run test` - Run tests -- `npm run lint` - Lint code -- `npm run format` - Format code - -### Architecture - -See [docs/architecture.md](docs/architecture.md) - -## Deployment - -### Cloudflare Pages - -1. Create Cloudflare Pages project -2. Connect to GitHub repository -3. Build command: `npm run build` -4. Build output: `dist` -5. Add secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) - -## License - -MIT -``` - -- [ ] **Step 2: Create CONTRIBUTING** - -Create `CONTRIBUTING.md`: -```markdown -# Contributing to GoodWebTools - -## Adding a New Tool - -1. Register the tool in `src/registry/tools.ts` -2. Create island component in `src/islands//.tsx` -3. Create worker in `src/tools//.worker.ts` -4. Create pure logic in `src/tools//.lib.ts` -5. Write tests in `src/tools//.lib.test.ts` -6. Test locally with `npm run dev` -7. Run tests with `npm run test` -8. Commit with conventional commit message - -## Code Standards - -- **TypeScript strict mode** - No `any` without justification -- **Explicit variable names** - Use units (byteSize, maxAgeMs, etc.) -- **DRY** - Don't repeat yourself -- **YAGNI** - You aren't gonna need it -- **TDD** - Test-driven development - -## Testing - -- Unit tests for pure logic -- Integration tests for services -- Manual testing for UI components - -## Commit Messages - -Use conventional commits: - -- `feat:` - New feature -- `fix:` - Bug fix -- `docs:` - Documentation -- `test:` - Tests -- `refactor:` - Code refactoring -- `style:` - Formatting -- `chore:` - Maintenance - -## Pull Requests - -1. Create feature branch -2. Make changes -3. Write tests -4. Run `npm run lint` and `npm run test` -5. Commit with conventional message -6. Open PR with description -``` - -- [ ] **Step 3: Create architecture docs** - -Create `docs/architecture.md`: -```markdown -# Architecture - -## Overview - -GoodWebTools uses a layered services architecture with Astro (static MPA) + React islands. - -## Directory Structure - -``` -src/ -├── pages/ # Astro routes (static HTML) -├── layouts/ # Page layouts -├── components/ # React components -│ ├── shell/ # Persisted shell -│ └── ui/ # Reusable UI -├── islands/ # Per-tool React islands (lazy) -├── tools/ # Pure logic + workers -├── services/ # Singleton services -├── registry/ # Tool manifest -├── hooks/ # React hooks -├── stores/ # Nanostores -├── styles/ # Global CSS -└── types/ # TypeScript types -``` - -## Key Concepts - -### Tool Registry - -Single source of truth for all tools. Each tool entry includes: -- Metadata (name, category, icon, keywords) -- Route -- Lazy load function -- Asset requirements - -### Shared Services - -Six singleton services: -1. **FileService** - Unified file input -2. **WorkerPool** - Worker lifecycle management -3. **AssetCache** - WASM/model caching with TTL -4. **DownloadService** - File downloads -5. **ProgressService** - Progress/toast UI -6. **PersistenceService** - Auto-save and navigation guards - -### Worker Pipeline - -Every tool follows: Island → Comlink → Worker → Logic → Results - -Workers run in separate threads, keeping UI responsive. - -### State Management - -- **Nanostores** for shell state (theme, workers) -- **React local state** for tool UI -- **LocalStorage** for persistence - -### PWA - -- Service worker for offline capability -- Workbox for caching strategies -- Manifest for installability - -## Privacy Guarantees - -1. **No egress** - Strict CSP blocks external requests -2. **Offline-capable** - Strongest privacy proof -3. **Open source** - Auditable code -4. **Reproducible builds** - Verify production matches source - -## Performance - -- Initial shell: < 120KB gzipped -- Manual chunk splitting -- Lazy island loading -- Progress bars for > 1MB assets -- Service worker caching with expiration -``` - -- [ ] **Step 4: Commit** - -```bash -git add . -git commit -m "docs: add README, CONTRIBUTING, and architecture documentation" -``` - ---- - -### Task 19: Final Testing & Performance Verification - -**Files:** -- None (testing and verification) - -**Interfaces:** -- Consumes: Complete app -- Produces: Verified working Phase 0 - -- [ ] **Step 1: Build production** - -Run: `npm run build` -Expected: Clean build, no errors - -- [ ] **Step 2: Check bundle size** - -Run: `ls -lh dist/**/*.js | head -20` -Expected: Individual chunks < 50KB, total shell < 120KB gzipped - -- [ ] **Step 3: Start preview server** - -Run: `npm run preview` - -- [ ] **Step 4: Test homepage** - -1. Open http://localhost:4321 -2. Verify theme toggle works -3. Verify theme persists on reload -4. Check Lighthouse score -Expected: > 95 performance - -- [ ] **Step 5: Test command palette** - -1. Press ⌘K -2. Palette opens -3. Type "hash" -4. See Hash File result -5. Press Enter -6. Navigate to tool -Expected: All working - -- [ ] **Step 6: Test Hash tool** - -1. Drop a file -2. See progress bar -3. See hash result -4. Download .sha256 file -5. Verify hash is correct (compare with `shasum -a 256 `) -Expected: Hash matches - -- [ ] **Step 7: Test offline** - -1. Install PWA -2. Disconnect network -3. Reload app -4. Use Hash tool -Expected: Works offline - -- [ ] **Step 8: Test worker cleanup** - -1. Use Hash tool -2. Navigate away -3. Check DevTools console -4. No errors -Expected: Worker terminated cleanly - -- [ ] **Step 9: Test View Transitions** - -1. Navigate between pages -2. Smooth animations -3. Shell persists (doesn't re-mount) -Expected: Smooth navigation - -- [ ] **Step 10: Verify privacy** - -1. Open DevTools Network tab -2. Clear network log -3. Use Hash tool -4. Check network requests -Expected: Zero external requests (only self-hosted assets) - -- [ ] **Step 11: Run all tests** - -Run: `npm run test` -Expected: All tests pass - -- [ ] **Step 12: Run linter** - -Run: `npm run lint` -Expected: No errors - -- [ ] **Step 13: Verify success criteria** - -Check each item from design spec: -- [ ] Shell loads < 1.5s (FCP) - Check Lighthouse -- [ ] Hash tool works offline after first use -- [ ] Command palette searches tools -- [ ] Theme toggle persists across navigation -- [ ] Worker terminates on route-away -- [ ] Progress bar shows for >1MB downloads (hash tool shows for all) -- [ ] Lighthouse score > 95 -- [ ] Build passes CI checks (local tests) -- [ ] PWA installable -- [ ] Privacy page renders correctly - -- [ ] **Step 14: Final commit** - -```bash -git add . -git commit -m "chore: Phase 0 Foundation complete - all success criteria met" -``` - -- [ ] **Step 15: Tag release** - -```bash -git tag -a v0.1.0 -m "Phase 0: Foundation" -git push origin v0.1.0 -``` - ---- - -## Execution Complete! - -Phase 0 Foundation is now complete with: - -✅ Astro + React + Tailwind setup -✅ Tool Registry system -✅ 6 Shared Services (File, Worker, Asset, Download, Progress, Persistence) -✅ Persisted Shell with command palette (⌘K) -✅ Theme system (light/dark) -✅ Hash File demo tool (full pipeline validation) -✅ PWA with offline capability -✅ Cloudflare Pages deployment ready -✅ Complete documentation -✅ All success criteria met - -**Next:** Phase 1 - Dev/Office Utilities (pure JS tools) \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-13-playground-tools.md b/docs/superpowers/plans/2026-07-13-playground-tools.md deleted file mode 100644 index f9d4afc..0000000 --- a/docs/superpowers/plans/2026-07-13-playground-tools.md +++ /dev/null @@ -1,1716 +0,0 @@ -# Playground Tools Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `Playground` tool category with two on-device dev sandboxes — a multi-file **Code Scratchpad** and a **SQLite Playground** — built on one shared, lazily-loaded, self-hosted Monaco editor. - -**Architecture:** A shared Monaco engine island (self-hosted workers, no CDN) underpins both tools. The Code Scratchpad keeps files in IndexedDB and reads/writes disk via the File System Access API. The SQLite Playground runs `@sqlite.org/sqlite-wasm` in a Comlink Web Worker with an OPFS SAHPool VFS (durable, no COOP/COEP), returning result sets to a React UI with a visual grid. - -**Tech Stack:** Astro 4 (static) · React 18 islands · TypeScript · Tailwind (Neo-Brutalism) · `monaco-editor` · `@sqlite.org/sqlite-wasm` · `comlink` · `idb` · Vitest. - -## Global Constraints - -- **No external network calls except same-origin assets.** Self-host every worker/wasm; never load Monaco or SQLite from a CDN. -- **Nothing is uploaded.** All processing is on-device. -- **Vite `worker.format: 'es'` is global** (required by mupdf/pdfjs) — every Vite-bundled worker is an ES-module worker. Monaco's workers must build/run under this; validate on the **production build** (`astro preview`), not Vite dev, which mishandles excluded wasm deps. -- **Registry-driven routing:** adding a `ToolDef` to `src/registry/tools.ts` auto-creates its `/tools/` route via `src/pages/tools/[tool].astro` `getStaticPaths`. No per-tool page file. -- **Lucide icons must be imported by name** on the single import line in `tools.ts`, or the build fails with "X is not defined". -- **Every `import` of a Lucide icon that isn't already imported must be added** to that line. -- **Design:** Neo-Brutalism — `border-2 border-border`, `shadow-brutal-sm`, `bg-muted`, `text-muted-foreground`, violet `bg-accent`/`text-accent-foreground`, uppercase bold labels. Match existing islands (e.g. `src/islands/media/Screenshot.tsx`). -- **Categories:** the `Category` union lives in `src/types/tool.ts`; the ordered list + color map live in `src/registry/categories.ts`. Both must include any new category or the shell/command-palette won't render it. -- **Verification is on the production build:** `npm run build` then `npx astro preview --port

`, headless-checked with a temporary `puppeteer-core` (uninstall after). This is the project's established practice. -- **Dark theme signal:** `document.documentElement.classList.contains('dark')`; the class is toggled by `src/components/shell/ThemeToggle.tsx` and initialised in `src/layouts/Base.astro`. - ---- - -## File Structure - -**Pure logic (unit-tested with Vitest):** -- `src/tools/playground/language.lib.ts` — filename → Monaco language id. -- `src/tools/playground/sql.lib.ts` — `splitStatements`, `classifyStatement`. -- `src/tools/playground/result.lib.ts` — `toCsv`, `toJson` for a result set. -- `src/tools/playground/schema.lib.ts` — `quoteIdent`, `mapColumnInfo`. - -**Monaco foundation:** -- `src/islands/playground/monaco-setup.ts` — one-time worker env + theme setup; re-exports `monaco`. -- `src/islands/playground/MonacoEditor.tsx` — client-only React wrapper. - -**Code Scratchpad:** -- `src/islands/playground/CodeScratchpad.tsx` — the tool island. -- `src/tools/playground/scratchpad.store.ts` — IndexedDB persistence (via `idb`). - -**SQLite Playground:** -- `src/tools/playground/sqlite.worker.ts` — Comlink worker wrapping sqlite-wasm + OPFS. -- `src/tools/playground/sqlite.client.ts` — typed client that wraps the worker. -- `src/islands/playground/SqlitePlayground.tsx` — the tool island (schema explorer + editor + results grid). - -**Wiring / config:** -- `src/types/tool.ts` — add `'Playground'` to `Category`. -- `src/registry/categories.ts` — add `'Playground'` + color. -- `src/registry/tools.ts` — two `ToolDef` entries + icons. -- `scripts/copy-wasm.mjs` — stage `sqlite3.wasm`/`sqlite3.mjs` into `public/sqlite/`. -- `astro.config.mjs` — `optimizeDeps.exclude` the sqlite wasm module. -- `.gitignore` — ignore `public/sqlite/`. -- `README.md` — Phase 9 section. - -**Shared cross-task types** (defined in `sqlite.worker.ts`, imported by client + UI): - -```ts -export interface QueryResult { - columns: string[]; - rows: unknown[][]; - rowsAffected: number; - elapsedMs: number; - kind: 'select' | 'ddl' | 'dml' | 'other'; // from classifyStatement, drives messaging -} -export interface ExecResult { - results: QueryResult[]; // one entry per executed statement - error?: string; // set if a statement threw; results holds those that ran before it -} -export interface ColumnInfo { name: string; type: string; pk: boolean; notnull: boolean; } -export interface SchemaObject { - type: 'table' | 'index' | 'view' | 'trigger'; - name: string; - sql: string; - columns?: ColumnInfo[]; // present for tables and views -} -export interface SqliteApi { - init(): Promise<{ persisted: boolean }>; - exec(sql: string): Promise; - schema(): Promise; - tableRows(name: string, limit: number, offset: number): Promise; - exportDb(): Promise; - importDb(bytes: Uint8Array): Promise; - reset(): Promise; - loadSample(): Promise; -} -``` - -> Note: this uses `ExecResult.results[]` (one `QueryResult` per statement) rather than the spec's `QueryResult.more?` — an internally-cleaner equivalent for multi-statement scripts. - ---- - -## Task 1: Playground category wiring - -**Files:** -- Modify: `src/types/tool.ts:3` -- Modify: `src/registry/categories.ts` - -**Interfaces:** -- Produces: the `'Playground'` `Category` value; the ordered `categories` list and `categoryColors` map both include it. Tasks 4 and 7 register tools under this category. - -- [ ] **Step 1: Add `'Playground'` to the `Category` union** - -In `src/types/tool.ts` line 3: - -```ts -export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media' | 'Playground'; -``` - -- [ ] **Step 2: Add it to the ordered list and color map** - -In `src/registry/categories.ts`: - -```ts -import type { Category } from '@/types/tool'; - -export const categories: Category[] = [ - 'Dev', - 'PDF', - 'Image', - 'Files', - 'Draw', - 'Media', - 'Playground' -]; - -export const categoryColors: Record = { - Dev: 'bg-blue-500', - PDF: 'bg-red-500', - Image: 'bg-green-500', - Files: 'bg-yellow-500', - Draw: 'bg-purple-500', - Media: 'bg-pink-500', - Playground: 'bg-orange-500' -}; -``` - -- [ ] **Step 3: Typecheck (the `Record` proves exhaustiveness)** - -Run: `npx tsc --noEmit 2>&1 | grep -E "categories|tool.ts" || echo "clean"` -Expected: `clean` (no missing-key error on `categoryColors`). - -- [ ] **Step 4: Commit** - -```bash -git add src/types/tool.ts src/registry/categories.ts -git commit -m "feat(playground): add Playground tool category" -``` - ---- - -## Task 2: Filename → language mapping (pure) - -**Files:** -- Create: `src/tools/playground/language.lib.ts` -- Test: `src/tools/playground/language.lib.test.ts` - -**Interfaces:** -- Produces: `extensionToLanguage(filename: string): string` — a Monaco language id. Consumed by Code Scratchpad (Task 3/4). - -- [ ] **Step 1: Write the failing test** - -`src/tools/playground/language.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { extensionToLanguage } from './language.lib'; - -describe('extensionToLanguage', () => { - it('maps known extensions', () => { - expect(extensionToLanguage('app.ts')).toBe('typescript'); - expect(extensionToLanguage('data.json')).toBe('json'); - expect(extensionToLanguage('notes.md')).toBe('markdown'); - expect(extensionToLanguage('query.sql')).toBe('sql'); - expect(extensionToLanguage('main.py')).toBe('python'); - expect(extensionToLanguage('style.css')).toBe('css'); - }); - - it('is case-insensitive', () => { - expect(extensionToLanguage('APP.TS')).toBe('typescript'); - }); - - it('handles dotted names by using the last segment', () => { - expect(extensionToLanguage('archive.tar.json')).toBe('json'); - }); - - it('falls back to plaintext for unknown or missing extensions', () => { - expect(extensionToLanguage('README')).toBe('plaintext'); - expect(extensionToLanguage('weird.xyz')).toBe('plaintext'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/tools/playground/language.lib.test.ts` -Expected: FAIL — "Failed to resolve import './language.lib'". - -- [ ] **Step 3: Write the implementation** - -`src/tools/playground/language.lib.ts`: - -```ts -const MAP: Record = { - ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', - mjs: 'javascript', cjs: 'javascript', - json: 'json', jsonc: 'json', - html: 'html', htm: 'html', - css: 'css', scss: 'scss', less: 'less', - md: 'markdown', markdown: 'markdown', - sql: 'sql', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', - cs: 'csharp', - rb: 'ruby', - php: 'php', - sh: 'shell', bash: 'shell', zsh: 'shell', - yaml: 'yaml', yml: 'yaml', - xml: 'xml', - toml: 'ini', ini: 'ini', - txt: 'plaintext', -}; - -/** Monaco language id for a filename, by its extension. Unknown → 'plaintext'. */ -export function extensionToLanguage(filename: string): string { - const ext = filename.includes('.') ? filename.split('.').pop()! : ''; - return MAP[ext.toLowerCase()] ?? 'plaintext'; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/tools/playground/language.lib.test.ts` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/tools/playground/language.lib.ts src/tools/playground/language.lib.test.ts -git commit -m "feat(playground): filename-to-language mapping" -``` - ---- - -## Task 3: Shared Monaco engine + minimal Code Scratchpad (validates the build risk) - -This task installs Monaco, self-hosts its workers, and proves it mounts on the **production build** by shipping a minimal single-buffer scratchpad. Task 4 adds tabs/disk/persistence. - -**Files:** -- Create: `src/islands/playground/monaco-setup.ts` -- Create: `src/islands/playground/MonacoEditor.tsx` -- Create: `src/islands/playground/CodeScratchpad.tsx` -- Modify: `src/registry/tools.ts` (import icon + register `code-scratchpad`) -- Modify: `package.json` (adds `monaco-editor`) - -**Interfaces:** -- Consumes: `extensionToLanguage` (Task 2). -- Produces: `monaco` (re-exported), `setupMonaco()`; `MonacoEditor` React component with props `{ value, language, onChange?, onMount?, readOnly?, options? }`. Consumed by Task 4 and Task 7. - -- [ ] **Step 1: Install Monaco** - -Run: `npm install monaco-editor@0.52.2` -Expected: adds `monaco-editor` to dependencies. - -- [ ] **Step 2: Write the Monaco setup module** - -`src/islands/playground/monaco-setup.ts`: - -```ts -import * as monaco from 'monaco-editor'; -import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; -import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; -import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; -import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; -import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; - -let done = false; - -/** One-time, idempotent Monaco setup: self-hosted workers + Neo-Brutalism themes. */ -export function setupMonaco(): void { - if (done) return; - done = true; - - // Self-host workers (no CDN) so the no-external-requests promise holds. - (self as unknown as { MonacoEnvironment: monaco.Environment }).MonacoEnvironment = { - getWorker(_workerId: string, label: string): Worker { - if (label === 'json') return new jsonWorker(); - if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker(); - if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker(); - if (label === 'typescript' || label === 'javascript') return new tsWorker(); - return new editorWorker(); - }, - }; - - monaco.editor.defineTheme('gwt-light', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#faf7f0', - 'editor.foreground': '#0a0a0a', - 'editorLineNumber.foreground': '#9b9689', - 'editor.selectionBackground': '#c4b5fd', - 'editorCursor.foreground': '#7c3aed', - }, - }); - monaco.editor.defineTheme('gwt-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#0a0a0a', - 'editor.foreground': '#faf7f0', - 'editorCursor.foreground': '#a78bfa', - }, - }); -} - -export function isDark(): boolean { - return typeof document !== 'undefined' && document.documentElement.classList.contains('dark'); -} - -export { monaco }; -``` - -- [ ] **Step 3: Write the React wrapper** - -`src/islands/playground/MonacoEditor.tsx`: - -```tsx -import { useEffect, useRef } from 'react'; -import { monaco, setupMonaco, isDark } from './monaco-setup'; - -interface MonacoEditorProps { - value: string; - language: string; - onChange?: (value: string) => void; - onMount?: (editor: monaco.editor.IStandaloneCodeEditor) => void; - readOnly?: boolean; - options?: monaco.editor.IStandaloneEditorConstructionOptions; - height?: string; -} - -export default function MonacoEditor({ - value, language, onChange, onMount, readOnly, options, height = '60vh', -}: MonacoEditorProps) { - const hostRef = useRef(null); - const editorRef = useRef(null); - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; - - // Create the editor once. - useEffect(() => { - setupMonaco(); - const host = hostRef.current!; - const editor = monaco.editor.create(host, { - value, - language, - readOnly, - theme: isDark() ? 'gwt-dark' : 'gwt-light', - automaticLayout: true, - minimap: { enabled: true }, - fontSize: 13, - scrollBeyondLastLine: false, - ...options, - }); - editorRef.current = editor; - const sub = editor.onDidChangeModelContent(() => onChangeRef.current?.(editor.getValue())); - onMount?.(editor); - - // Follow the app's light/dark toggle. - const observer = new MutationObserver(() => - monaco.editor.setTheme(isDark() ? 'gwt-dark' : 'gwt-light') - ); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); - - return () => { sub.dispose(); observer.disconnect(); editor.dispose(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Push external value changes into the editor without moving the cursor. - useEffect(() => { - const editor = editorRef.current; - if (editor && value !== editor.getValue()) editor.setValue(value); - }, [value]); - - // Update language when the active file/tab changes. - useEffect(() => { - const model = editorRef.current?.getModel(); - if (model) monaco.editor.setModelLanguage(model, language); - }, [language]); - - return

; -} -``` - -- [ ] **Step 4: Write the minimal single-buffer scratchpad** - -`src/islands/playground/CodeScratchpad.tsx` (temporary MVP; Task 4 replaces the body): - -```tsx -import { useState } from 'react'; -import MonacoEditor from './MonacoEditor'; - -export default function CodeScratchpad() { - const [code, setCode] = useState('// Scratchpad\nconst hello = "world";\n'); - return ( -
-

- A VS Code-grade editor, fully on-device. Multi-cursor, move/copy line, column select — all native. -

- -
- ); -} -``` - -- [ ] **Step 5: Register the tool** - -In `src/registry/tools.ts`, add `Code2` to the Lucide import line, then add this entry (place it before the `whiteboard` entry): - -```ts - { - id: 'code-scratchpad', - name: 'Code Scratchpad', - category: 'Playground', - route: '/tools/code-scratchpad', - keywords: ['code', 'editor', 'monaco', 'vscode', 'scratchpad', 'text', 'multi-cursor'], - icon: Code2, - summary: 'VS Code-grade multi-file editor, on-device', - load: () => import('@/islands/playground/CodeScratchpad'), - status: 'stable' - }, -``` - -- [ ] **Step 6: Exclude Monaco from Vite pre-bundling issues is NOT needed — build and verify the worker-format risk** - -Run: `npm run build 2>&1 | tail -3` -Expected: `[build] Complete!` with the page count increased by 1. If the build errors on a worker, see the fallback note at the end of this task. - -- [ ] **Step 7: Headless-verify Monaco mounts and its worker responds on the production build** - -Run: - -```bash -npx astro preview --port 4350 > /tmp/pv.log 2>&1 & -sleep 4 -npm install -D puppeteer-core --legacy-peer-deps >/dev/null 2>&1 -cat > /tmp/mcheck.mjs << 'EOF' -import puppeteer from 'puppeteer-core'; -const b = await puppeteer.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true, args: ['--no-sandbox'] }); -const p = await b.newPage(); -let errs=[]; p.on('pageerror', e=>errs.push(e.message.slice(0,140))); -await p.goto('http://localhost:4350/tools/code-scratchpad',{waitUntil:'networkidle2',timeout:30000}); -await new Promise(r=>setTimeout(r,1500)); -const mounted = await p.evaluate(()=>!!document.querySelector('.monaco-editor .view-lines')); -// Type into the editor and confirm the model updates (proves the editor is live). -await p.evaluate(()=>{ const el=document.querySelector('.monaco-editor textarea'); el && el.focus(); }); -await p.keyboard.type('const x = 42;'); -await new Promise(r=>setTimeout(r,300)); -const hasText = await p.evaluate(()=>document.querySelector('.monaco-editor')?.textContent?.includes('42')); -console.log('monaco mounted:', mounted, '| edit works:', !!hasText, '| pageerrors:', errs.length?errs.join(';'):'none'); -await b.close(); -EOF -node /tmp/mcheck.mjs -kill %1 2>/dev/null -npm uninstall puppeteer-core --legacy-peer-deps >/dev/null 2>&1 -``` - -Expected: `monaco mounted: true | edit works: true | pageerrors: none`. - -- [ ] **Step 8: Commit** - -```bash -git add src/islands/playground/ src/registry/tools.ts package.json package-lock.json -git commit -m "feat(playground): shared Monaco engine + minimal Code Scratchpad" -``` - -> **If Step 6/7 fails on the worker** (module-worker vs classic, mirroring the ffmpeg core issue): keep the `?worker` imports but instantiate the base worker from a URL instead — replace the `getWorker` body's fallback with `new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker?worker&url', import.meta.url), { type: 'module' })`, and if module workers still fail, add `vite-plugin-monaco-editor-esm` to `astro.config.mjs`'s `vite.plugins`. Re-run Steps 6–7 until green before committing. - ---- - -## Task 4: Code Scratchpad — tabs, disk I/O, IndexedDB autosave - -**Files:** -- Create: `src/tools/playground/scratchpad.store.ts` -- Modify: `src/islands/playground/CodeScratchpad.tsx` (full replacement) -- Modify: `package.json` (adds `idb`) - -**Interfaces:** -- Consumes: `MonacoEditor` (Task 3), `extensionToLanguage` (Task 2), `downloadService` from `@/services/download.service`. -- Produces: the finished Code Scratchpad. `scratchpad.store.ts` exports `loadFiles(): Promise`, `saveFiles(files: ScratchFile[]): Promise`, and the `ScratchFile` type. - -- [ ] **Step 1: Install `idb`** - -Run: `npm install idb@8.0.0` -Expected: adds `idb` to dependencies. - -- [ ] **Step 2: Write the IndexedDB store** - -`src/tools/playground/scratchpad.store.ts`: - -```ts -import { openDB, type IDBPDatabase } from 'idb'; - -export interface ScratchFile { - id: string; - name: string; - language: string; - content: string; -} - -const DB_NAME = 'gwt-scratchpad'; -const STORE = 'files'; - -let dbPromise: Promise | null = null; -function db(): Promise { - if (!dbPromise) { - dbPromise = openDB(DB_NAME, 1, { - upgrade(database) { - if (!database.objectStoreNames.contains(STORE)) { - database.createObjectStore(STORE, { keyPath: 'id' }); - } - }, - }); - } - return dbPromise; -} - -export async function loadFiles(): Promise { - return (await db()).getAll(STORE) as Promise; -} - -export async function saveFiles(files: ScratchFile[]): Promise { - const database = await db(); - const tx = database.transaction(STORE, 'readwrite'); - await tx.objectStore(STORE).clear(); - for (const f of files) await tx.objectStore(STORE).put(f); - await tx.done; -} -``` - -- [ ] **Step 3: Replace the scratchpad island with the full version** - -`src/islands/playground/CodeScratchpad.tsx`: - -```tsx -import { useEffect, useRef, useState } from 'react'; -import { Plus, X, FolderOpen, Save } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import MonacoEditor from './MonacoEditor'; -import { extensionToLanguage } from '@/tools/playground/language.lib'; -import { loadFiles, saveFiles, type ScratchFile } from '@/tools/playground/scratchpad.store'; -import { downloadService } from '@/services/download.service'; - -let counter = 0; -const newId = () => `f${Date.now()}-${counter++}`; - -function blankFile(): ScratchFile { - return { id: newId(), name: 'untitled.txt', language: 'plaintext', content: '' }; -} - -export default function CodeScratchpad() { - const [files, setFiles] = useState([]); - const [activeId, setActiveId] = useState(''); - // File System Access handles, kept out of IndexedDB (not structured-clonable across our store). - const handles = useRef>(new Map()); - const [ready, setReady] = useState(false); - - // Restore persisted tabs on mount. - useEffect(() => { - loadFiles().then((saved) => { - const initial = saved.length ? saved : [blankFile()]; - setFiles(initial); - setActiveId(initial[0].id); - setReady(true); - }); - }, []); - - // Debounced autosave whenever files change. - useEffect(() => { - if (!ready) return; - const t = setTimeout(() => { void saveFiles(files); }, 400); - return () => clearTimeout(t); - }, [files, ready]); - - const active = files.find((f) => f.id === activeId) ?? null; - - const updateActive = (content: string) => - setFiles((fs) => fs.map((f) => (f.id === activeId ? { ...f, content } : f))); - - const addFile = () => { - const name = prompt('File name (extension sets the language):', 'untitled.txt'); - if (name === null) return; - const f: ScratchFile = { id: newId(), name: name || 'untitled.txt', language: extensionToLanguage(name || 'untitled.txt'), content: '' }; - setFiles((fs) => [...fs, f]); - setActiveId(f.id); - }; - - const renameFile = (id: string) => { - const current = files.find((f) => f.id === id); - if (!current) return; - const name = prompt('Rename file:', current.name); - if (name === null || !name) return; - setFiles((fs) => fs.map((f) => (f.id === id ? { ...f, name, language: extensionToLanguage(name) } : f))); - }; - - const closeFile = (id: string) => { - handles.current.delete(id); - setFiles((fs) => { - const next = fs.filter((f) => f.id !== id); - const result = next.length ? next : [blankFile()]; - if (id === activeId) setActiveId(result[0].id); - return result; - }); - }; - - const openFromDisk = async () => { - if ('showOpenFilePicker' in window) { - try { - const [handle] = await (window as unknown as { showOpenFilePicker: (o?: unknown) => Promise }).showOpenFilePicker(); - const file = await handle.getFile(); - const content = await file.text(); - const f: ScratchFile = { id: newId(), name: file.name, language: extensionToLanguage(file.name), content }; - handles.current.set(f.id, handle); - setFiles((fs) => [...fs, f]); - setActiveId(f.id); - } catch (e) { - if ((e as Error).name !== 'AbortError') alert('Could not open file.'); - } - } else { - const input = document.createElement('input'); - input.type = 'file'; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return; - const content = await file.text(); - const f: ScratchFile = { id: newId(), name: file.name, language: extensionToLanguage(file.name), content }; - setFiles((fs) => [...fs, f]); - setActiveId(f.id); - }; - input.click(); - } - }; - - const saveActive = async () => { - if (!active) return; - const handle = handles.current.get(active.id); - const blob = new Blob([active.content], { type: 'text/plain' }); - if (handle) { - const writable = await (handle as unknown as { createWritable: () => Promise<{ write: (b: Blob) => Promise; close: () => Promise }> }).createWritable(); - await writable.write(blob); - await writable.close(); - return; - } - if ('showSaveFilePicker' in window) { - try { - const h = await (window as unknown as { showSaveFilePicker: (o?: unknown) => Promise }).showSaveFilePicker({ suggestedName: active.name }); - handles.current.set(active.id, h); - const writable = await (h as unknown as { createWritable: () => Promise<{ write: (b: Blob) => Promise; close: () => Promise }> }).createWritable(); - await writable.write(blob); - await writable.close(); - return; - } catch (e) { - if ((e as Error).name === 'AbortError') return; - } - } - await downloadService.download(blob, active.name); - }; - - if (!ready) return

Loading…

; - - return ( -
-
-
- {files.map((f) => ( -
renameFile(f.id)} - className={`flex items-center gap-1 border-2 px-2 py-1 text-sm ${f.id === activeId ? 'border-border bg-accent text-accent-foreground' : 'border-border bg-muted'}`} - > - - -
- ))} - -
-
- - -
-
- -

- Tabs autosave locally. Double-click a tab to rename. Move line ⌥↑/↓, add cursor ⌘⌥↑/↓, - select-next ⌘D, all occurrences ⌘⇧L, column select ⇧⌥+drag. On-device only. -

- - {active && ( - - )} -
- ); -} -``` - -- [ ] **Step 4: Build** - -Run: `npm run build 2>&1 | tail -2` -Expected: `[build] Complete!`. - -- [ ] **Step 5: Headless-verify tabs + persistence across reload** - -Run: - -```bash -npx astro preview --port 4351 > /tmp/pv.log 2>&1 & -sleep 4 -npm install -D puppeteer-core --legacy-peer-deps >/dev/null 2>&1 -cat > /tmp/scheck.mjs << 'EOF' -import puppeteer from 'puppeteer-core'; -const b = await puppeteer.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true, args: ['--no-sandbox'] }); -const p = await b.newPage(); -await p.goto('http://localhost:4351/tools/code-scratchpad',{waitUntil:'networkidle2',timeout:30000}); -await new Promise(r=>setTimeout(r,1200)); -await p.evaluate(()=>{ const el=document.querySelector('.monaco-editor textarea'); el && el.focus(); }); -await p.keyboard.type('persist me'); -await new Promise(r=>setTimeout(r,700)); // allow the 400ms autosave debounce -await p.reload({waitUntil:'networkidle2'}); -await new Promise(r=>setTimeout(r,1200)); -const restored = await p.evaluate(()=>document.querySelector('.monaco-editor')?.textContent?.includes('persist me')); -console.log('scratchpad persists across reload:', !!restored); -await b.close(); -EOF -node /tmp/scheck.mjs -kill %1 2>/dev/null -npm uninstall puppeteer-core --legacy-peer-deps >/dev/null 2>&1 -``` - -Expected: `scratchpad persists across reload: true`. - -- [ ] **Step 6: Commit** - -```bash -git add src/islands/playground/CodeScratchpad.tsx src/tools/playground/scratchpad.store.ts package.json package-lock.json -git commit -m "feat(playground): Code Scratchpad tabs, disk I/O, autosave" -``` - ---- - -## Task 5: SQLite pure logic libs - -**Files:** -- Create: `src/tools/playground/sql.lib.ts` + `src/tools/playground/sql.lib.test.ts` -- Create: `src/tools/playground/result.lib.ts` + `src/tools/playground/result.lib.test.ts` -- Create: `src/tools/playground/schema.lib.ts` + `src/tools/playground/schema.lib.test.ts` - -**Interfaces:** -- Produces: - - `splitStatements(sql: string): string[]` - - `classifyStatement(sql: string): 'select' | 'ddl' | 'dml' | 'other'` - - `toCsv(result: QueryResult): string`, `toJson(result: QueryResult): string` - - `quoteIdent(name: string): string`, `mapColumnInfo(rows: RawPragmaRow[]): ColumnInfo[]` -- Consumed by the SQLite worker (Task 6) and UI (Task 7). `QueryResult`/`ColumnInfo` are imported from `sqlite.worker.ts` (Task 6) — but to keep these libs test-independent, they define their own minimal structural types (below) that match those shapes. - -- [ ] **Step 1: Write failing tests for `sql.lib`** - -`src/tools/playground/sql.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { splitStatements, classifyStatement } from './sql.lib'; - -describe('splitStatements', () => { - it('splits on semicolons', () => { - expect(splitStatements('SELECT 1; SELECT 2;')).toEqual(['SELECT 1', 'SELECT 2']); - }); - it('ignores semicolons inside string literals', () => { - expect(splitStatements("INSERT INTO t VALUES ('a;b'); SELECT 1")).toEqual([ - "INSERT INTO t VALUES ('a;b')", - 'SELECT 1', - ]); - }); - it('ignores semicolons in line and block comments', () => { - expect(splitStatements('SELECT 1; -- a;b\nSELECT 2; /* c;d */ SELECT 3')).toEqual([ - 'SELECT 1', - '-- a;b\nSELECT 2', - '/* c;d */ SELECT 3', - ]); - }); - it('drops trailing empty statements', () => { - expect(splitStatements('SELECT 1; ;')).toEqual(['SELECT 1']); - }); -}); - -describe('classifyStatement', () => { - it('classifies by leading keyword, skipping comments', () => { - expect(classifyStatement(' -- hi\n SELECT * FROM t')).toBe('select'); - expect(classifyStatement('WITH x AS (SELECT 1) SELECT * FROM x')).toBe('select'); - expect(classifyStatement('PRAGMA table_info(t)')).toBe('select'); - expect(classifyStatement('CREATE TABLE t (a)')).toBe('ddl'); - expect(classifyStatement('DROP TABLE t')).toBe('ddl'); - expect(classifyStatement('INSERT INTO t VALUES (1)')).toBe('dml'); - expect(classifyStatement('UPDATE t SET a=1')).toBe('dml'); - expect(classifyStatement('BEGIN')).toBe('other'); - }); -}); -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `npx vitest run src/tools/playground/sql.lib.test.ts` -Expected: FAIL — cannot resolve `./sql.lib`. - -- [ ] **Step 3: Implement `sql.lib`** - -`src/tools/playground/sql.lib.ts`: - -```ts -/** - * Split a multi-statement SQL script on top-level semicolons, respecting single - * ('), double (") and backtick (`) quoted spans, `--` line comments and - * `/* *​/` block comments. Returns trimmed, non-empty statements. - */ -export function splitStatements(sql: string): string[] { - const out: string[] = []; - let buf = ''; - let i = 0; - const n = sql.length; - while (i < n) { - const c = sql[i]; - const next = sql[i + 1]; - // Line comment - if (c === '-' && next === '-') { - const end = sql.indexOf('\n', i); - const stop = end === -1 ? n : end; - buf += sql.slice(i, stop); - i = stop; - continue; - } - // Block comment - if (c === '/' && next === '*') { - const end = sql.indexOf('*/', i + 2); - const stop = end === -1 ? n : end + 2; - buf += sql.slice(i, stop); - i = stop; - continue; - } - // Quoted span - if (c === "'" || c === '"' || c === '`') { - let j = i + 1; - while (j < n) { - if (sql[j] === c) { - if (sql[j + 1] === c) { j += 2; continue; } // doubled = escaped - break; - } - j++; - } - buf += sql.slice(i, Math.min(j + 1, n)); - i = j + 1; - continue; - } - if (c === ';') { - if (buf.trim()) out.push(buf.trim()); - buf = ''; - i++; - continue; - } - buf += c; - i++; - } - if (buf.trim()) out.push(buf.trim()); - return out; -} - -const LEADING_COMMENTS = /^(\s|--[^\n]*\n|\/\*[\s\S]*?\*\/)+/; - -export function classifyStatement(sql: string): 'select' | 'ddl' | 'dml' | 'other' { - const cleaned = sql.replace(LEADING_COMMENTS, ''); - const kw = (cleaned.match(/^\s*([a-zA-Z]+)/)?.[1] ?? '').toUpperCase(); - if (kw === 'SELECT' || kw === 'WITH' || kw === 'PRAGMA' || kw === 'EXPLAIN' || kw === 'VALUES') return 'select'; - if (kw === 'CREATE' || kw === 'ALTER' || kw === 'DROP' || kw === 'REINDEX') return 'ddl'; - if (kw === 'INSERT' || kw === 'UPDATE' || kw === 'DELETE' || kw === 'REPLACE') return 'dml'; - return 'other'; -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `npx vitest run src/tools/playground/sql.lib.test.ts` -Expected: PASS. - -- [ ] **Step 5: Write failing tests for `result.lib`** - -`src/tools/playground/result.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { toCsv, toJson } from './result.lib'; - -const result = { - columns: ['id', 'name'], - rows: [[1, 'Ann'], [2, 'B,x']], - rowsAffected: 0, - elapsedMs: 1, -}; - -describe('toCsv', () => { - it('renders a header and quotes fields with commas', () => { - expect(toCsv(result)).toBe('id,name\n1,Ann\n2,"B,x"'); - }); - it('escapes embedded quotes and nulls', () => { - expect(toCsv({ columns: ['a'], rows: [['he"llo'], [null]], rowsAffected: 0, elapsedMs: 0 })) - .toBe('a\n"he""llo"\n'); - }); -}); - -describe('toJson', () => { - it('maps columns to values per row', () => { - expect(JSON.parse(toJson(result))).toEqual([ - { id: 1, name: 'Ann' }, - { id: 2, name: 'B,x' }, - ]); - }); -}); -``` - -- [ ] **Step 6: Run to verify failure** - -Run: `npx vitest run src/tools/playground/result.lib.test.ts` -Expected: FAIL — cannot resolve `./result.lib`. - -- [ ] **Step 7: Implement `result.lib`** - -`src/tools/playground/result.lib.ts`: - -```ts -interface ResultLike { - columns: string[]; - rows: unknown[][]; -} - -function cell(v: unknown): string { - if (v === null || v === undefined) return ''; - const s = String(v); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; -} - -export function toCsv(result: ResultLike): string { - const header = result.columns.map(cell).join(','); - const body = result.rows.map((r) => r.map(cell).join(',')).join('\n'); - return body ? `${header}\n${body}` : header; -} - -export function toJson(result: ResultLike): string { - const objects = result.rows.map((row) => { - const o: Record = {}; - result.columns.forEach((c, i) => { o[c] = row[i] ?? null; }); - return o; - }); - return JSON.stringify(objects, null, 2); -} -``` - -- [ ] **Step 8: Run to verify pass** - -Run: `npx vitest run src/tools/playground/result.lib.test.ts` -Expected: PASS. - -- [ ] **Step 9: Write failing tests for `schema.lib`** - -`src/tools/playground/schema.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { quoteIdent, mapColumnInfo } from './schema.lib'; - -describe('quoteIdent', () => { - it('wraps in double quotes and escapes embedded quotes', () => { - expect(quoteIdent('users')).toBe('"users"'); - expect(quoteIdent('we"ird')).toBe('"we""ird"'); - }); -}); - -describe('mapColumnInfo', () => { - it('maps PRAGMA table_info rows to ColumnInfo', () => { - const rows = [ - { name: 'id', type: 'INTEGER', notnull: 1, pk: 1 }, - { name: 'email', type: 'TEXT', notnull: 0, pk: 0 }, - ]; - expect(mapColumnInfo(rows)).toEqual([ - { name: 'id', type: 'INTEGER', pk: true, notnull: true }, - { name: 'email', type: 'TEXT', pk: false, notnull: false }, - ]); - }); -}); -``` - -- [ ] **Step 10: Run to verify failure** - -Run: `npx vitest run src/tools/playground/schema.lib.test.ts` -Expected: FAIL — cannot resolve `./schema.lib`. - -- [ ] **Step 11: Implement `schema.lib`** - -`src/tools/playground/schema.lib.ts`: - -```ts -export interface RawPragmaRow { - name: string; - type: string; - notnull: number; - pk: number; -} -export interface ColumnInfo { - name: string; - type: string; - pk: boolean; - notnull: boolean; -} - -/** SQLite-safe double-quoted identifier. */ -export function quoteIdent(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -export function mapColumnInfo(rows: RawPragmaRow[]): ColumnInfo[] { - return rows.map((r) => ({ - name: r.name, - type: r.type, - pk: !!r.pk, - notnull: !!r.notnull, - })); -} -``` - -- [ ] **Step 12: Run to verify pass, then run the whole suite** - -Run: `npx vitest run src/tools/playground/` -Expected: PASS (all three lib test files). -Run: `npx vitest run 2>&1 | grep -E "Test Files|Tests "` -Expected: totals increased; all pass. - -- [ ] **Step 13: Commit** - -```bash -git add src/tools/playground/sql.lib.ts src/tools/playground/sql.lib.test.ts src/tools/playground/result.lib.ts src/tools/playground/result.lib.test.ts src/tools/playground/schema.lib.ts src/tools/playground/schema.lib.test.ts -git commit -m "feat(playground): SQLite pure logic libs (split/classify/serialize/schema)" -``` - ---- - -## Task 6: SQLite worker + client (sqlite-wasm + OPFS) - -**Files:** -- Create: `src/tools/playground/sqlite.worker.ts` -- Create: `src/tools/playground/sqlite.client.ts` -- Modify: `scripts/copy-wasm.mjs` (stage sqlite wasm to `public/sqlite/`) -- Modify: `astro.config.mjs` (`optimizeDeps.exclude`) -- Modify: `.gitignore` (`public/sqlite/`) -- Modify: `package.json` (adds `@sqlite.org/sqlite-wasm`) - -**Interfaces:** -- Consumes: `splitStatements`, `classifyStatement` (Task 5), `quoteIdent`, `mapColumnInfo` (Task 5). -- Produces: `SqliteApi` (see shared types), and `sqlite.client.ts` exporting `getSqlite(): Remote` (a Comlink-wrapped singleton). Consumed by Task 7. - -- [ ] **Step 1: Install sqlite-wasm** - -Run: `npm install @sqlite.org/sqlite-wasm@3.50.1-build1` -Expected: adds the dependency. (If that exact version is unavailable, use the latest `3.x` `@sqlite.org/sqlite-wasm`; the API used below is stable across 3.x.) - -- [ ] **Step 2: Stage the wasm into `public/sqlite/`** - -Append to `scripts/copy-wasm.mjs` (before the final `console.log`): - -```js -// SQLite WASM (served same-origin at /sqlite/; loaded by the sqlite.worker). -const sqliteSrc = 'node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm'; -if (existsSync(sqliteSrc)) { - mkdirSync('public/sqlite', { recursive: true }); - for (const f of ['sqlite3.wasm', 'sqlite3.mjs']) { - if (existsSync(`${sqliteSrc}/${f}`)) copyFileSync(`${sqliteSrc}/${f}`, `public/sqlite/${f}`); - } -} -``` - -- [ ] **Step 3: Stage now and confirm the files exist** - -Run: -```bash -node scripts/copy-wasm.mjs -ls -la public/sqlite/ -``` -Expected: `sqlite3.wasm` (~1 MB) and `sqlite3.mjs` present. - -- [ ] **Step 4: Gitignore the staged assets** - -Add to `.gitignore`: -``` -public/sqlite/ -``` - -- [ ] **Step 5: Exclude sqlite-wasm from Vite pre-bundling** - -In `astro.config.mjs`, add `'@sqlite.org/sqlite-wasm'` to the `optimizeDeps.exclude` array (the line that already lists `onnxruntime-web`, `@ffmpeg/ffmpeg`, `@ffmpeg/util`): - -```js - exclude: ['pdfjs-dist/build/pdf.worker.min.mjs', 'mupdf', 'libarchive.js', 'onnxruntime-web', '@ffmpeg/ffmpeg', '@ffmpeg/util', '@sqlite.org/sqlite-wasm'], -``` - -- [ ] **Step 6: Write the worker** - -`src/tools/playground/sqlite.worker.ts`: - -```ts -import * as Comlink from 'comlink'; -import { splitStatements, classifyStatement } from './sql.lib'; -import { quoteIdent, mapColumnInfo, type RawPragmaRow } from './schema.lib'; - -export interface QueryResult { - columns: string[]; - rows: unknown[][]; - rowsAffected: number; - elapsedMs: number; - kind: 'select' | 'ddl' | 'dml' | 'other'; -} -export interface ExecResult { - results: QueryResult[]; - error?: string; -} -export interface ColumnInfo { name: string; type: string; pk: boolean; notnull: boolean; } -export interface SchemaObject { - type: 'table' | 'index' | 'view' | 'trigger'; - name: string; - sql: string; - columns?: ColumnInfo[]; -} -export interface SqliteApi { - init(): Promise<{ persisted: boolean }>; - exec(sql: string): Promise; - schema(): Promise; - tableRows(name: string, limit: number, offset: number): Promise; - exportDb(): Promise; - importDb(bytes: Uint8Array): Promise; - reset(): Promise; - loadSample(): Promise; -} - -/* eslint-disable @typescript-eslint/no-explicit-any */ -const DB_PATH = '/playground.sqlite'; -const SAMPLE = ` -CREATE TABLE artists (id INTEGER PRIMARY KEY, name TEXT NOT NULL); -CREATE TABLE albums (id INTEGER PRIMARY KEY, title TEXT NOT NULL, artist_id INTEGER REFERENCES artists(id), year INTEGER); -CREATE INDEX idx_albums_artist ON albums(artist_id); -INSERT INTO artists (id, name) VALUES (1,'Radiohead'),(2,'Miles Davis'),(3,'Aphex Twin'); -INSERT INTO albums (title, artist_id, year) VALUES - ('OK Computer',1,1997),('In Rainbows',1,2007), - ('Kind of Blue',2,1959),('Bitches Brew',2,1970), - ('Selected Ambient Works 85-92',3,1992); -`; - -let sqlite3: any = null; -let pool: any = null; -let db: any = null; -let persisted = false; - -async function ensure(): Promise { - if (db) return; - const mod = await import('@sqlite.org/sqlite-wasm'); - const init = (mod as any).default; - sqlite3 = await init({ locateFile: () => new URL('/sqlite/sqlite3.wasm', location.origin).href }); - try { - pool = await sqlite3.installOpfsSAHPoolVfs({ name: 'gwt-playground' }); - db = new pool.OpfsSAHPoolDb(DB_PATH); - persisted = true; - } catch { - // OPFS SAHPool unavailable (older Safari) — fall back to in-memory. - db = new sqlite3.oo1.DB(':memory:', 'c'); - persisted = false; - } -} - -/** Run one statement, capturing columns/rows/affected/kind. */ -function runOne(sql: string): QueryResult { - const columns: string[] = []; - const rows: unknown[][] = []; - const t0 = performance.now(); - db.exec({ sql, rowMode: 'array', columnNames: columns, resultRows: rows }); - const elapsedMs = performance.now() - t0; - const rowsAffected = db.changes(); - return { columns: columns.slice(), rows, rowsAffected, elapsedMs, kind: classifyStatement(sql) }; -} - -const api: SqliteApi = { - async init() { - await ensure(); - return { persisted }; - }, - - async exec(sql: string): Promise { - await ensure(); - const results: QueryResult[] = []; - for (const stmt of splitStatements(sql)) { - try { - results.push(runOne(stmt)); - } catch (e) { - return { results, error: (e as Error).message }; - } - } - return { results }; - }, - - async schema(): Promise { - await ensure(); - const master: any[] = []; - db.exec({ - sql: "SELECT type,name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name", - rowMode: 'object', - resultRows: master, - }); - const objects: SchemaObject[] = []; - for (const r of master) { - const obj: SchemaObject = { type: r.type, name: r.name, sql: r.sql || '' }; - if (r.type === 'table' || r.type === 'view') { - const info: RawPragmaRow[] = []; - db.exec({ sql: `PRAGMA table_info(${quoteIdent(r.name)})`, rowMode: 'object', resultRows: info }); - obj.columns = mapColumnInfo(info); - } - objects.push(obj); - } - return objects; - }, - - async tableRows(name: string, limit: number, offset: number): Promise { - await ensure(); - return runOne(`SELECT * FROM ${quoteIdent(name)} LIMIT ${Math.max(0, limit)} OFFSET ${Math.max(0, offset)}`); - }, - - async exportDb(): Promise { - await ensure(); - return sqlite3.capi.sqlite3_js_db_export(db); - }, - - async importDb(bytes: Uint8Array): Promise { - await ensure(); - db.close(); - if (pool) { - await pool.importDb(DB_PATH, bytes); - db = new pool.OpfsSAHPoolDb(DB_PATH); - } else { - db = new sqlite3.oo1.DB(':memory:', 'c'); - const p = sqlite3.wasm.allocFromTypedArray(bytes); - sqlite3.capi.sqlite3_deserialize(db, 'main', p, bytes.length, bytes.length, sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE); - } - }, - - async reset(): Promise { - await ensure(); - const tables: any[] = []; - db.exec({ - sql: "SELECT type,name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'", - rowMode: 'object', - resultRows: tables, - }); - // Drop views/triggers/indexes first, then tables. - for (const order of ['trigger', 'view', 'index', 'table']) { - for (const t of tables.filter((x) => x.type === order)) { - db.exec(`DROP ${order.toUpperCase()} IF EXISTS ${quoteIdent(t.name)}`); - } - } - }, - - async loadSample(): Promise { - await ensure(); - await this.reset(); - db.exec(SAMPLE); - }, -}; - -Comlink.expose(api); -``` - -- [ ] **Step 7: Write the client** - -`src/tools/playground/sqlite.client.ts`: - -```ts -import * as Comlink from 'comlink'; -import type { Remote } from 'comlink'; -import type { SqliteApi } from './sqlite.worker'; -import SqliteWorker from './sqlite.worker?worker'; - -let remote: Remote | null = null; - -/** Comlink-wrapped SQLite engine, created once per session. */ -export function getSqlite(): Remote { - if (!remote) { - const worker = new SqliteWorker(); - worker.addEventListener('error', (e) => console.error('[sqlite worker]', e.message)); - remote = Comlink.wrap(worker); - } - return remote; -} -``` - -- [ ] **Step 8: Build** - -Run: `npm run build 2>&1 | tail -2` -Expected: `[build] Complete!`. - -- [ ] **Step 9: Typecheck the worker + client (runtime is verified in Task 7)** - -There is no UI yet, and a production preview doesn't serve `/src` modules to probe the worker directly, so the engine's **runtime** verification is folded into Task 7 Step 5 (which drives the identical client → worker → sqlite-wasm → OPFS path through the real UI). Here, prove the types and module graph resolve: - -Run: `npx tsc --noEmit 2>&1 | grep -E "playground/sqlite" || echo "clean"` -Expected: `clean` (no type errors in `sqlite.worker.ts` / `sqlite.client.ts`). - -Run: `npm run build 2>&1 | tail -2` -Expected: `[build] Complete!` — confirms the `?worker` import and sqlite-wasm exclusion bundle without error. - -- [ ] **Step 10: Commit** - -```bash -git add src/tools/playground/sqlite.worker.ts src/tools/playground/sqlite.client.ts scripts/copy-wasm.mjs astro.config.mjs .gitignore package.json package-lock.json -git commit -m "feat(playground): SQLite worker + client (sqlite-wasm + OPFS SAHPool)" -``` - ---- - -## Task 7: SQLite Playground UI - -**Files:** -- Create: `src/islands/playground/SqlitePlayground.tsx` -- Modify: `src/registry/tools.ts` (import icon + register `sqlite-playground`) - -**Interfaces:** -- Consumes: `getSqlite` (Task 6), `MonacoEditor` (Task 3), `classifyStatement` (Task 5), `toCsv`/`toJson` (Task 5), `downloadService`. -- Produces: the finished tool. - -- [ ] **Step 1: Write the island** - -`src/islands/playground/SqlitePlayground.tsx`: - -```tsx -import { useEffect, useRef, useState } from 'react'; -import { Play, Database, Download, Upload, FlaskConical, RotateCcw, Table2 } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Alert } from '@/components/ui/Alert'; -import MonacoEditor from './MonacoEditor'; -import { getSqlite } from '@/tools/playground/sqlite.client'; -import { toCsv, toJson } from '@/tools/playground/result.lib'; -import { downloadService } from '@/services/download.service'; -import type { monaco } from './monaco-setup'; -import type { QueryResult, SchemaObject } from '@/tools/playground/sqlite.worker'; - -const STARTER = 'SELECT name FROM sqlite_master;\n'; - -export default function SqlitePlayground() { - const [sql, setSql] = useState(STARTER); - const [schema, setSchema] = useState([]); - const [grids, setGrids] = useState([]); - const [activeGrid, setActiveGrid] = useState(0); - const [message, setMessage] = useState(''); - const [error, setError] = useState(''); - const [busy, setBusy] = useState(false); - const [persisted, setPersisted] = useState(true); - const editorRef = useRef(null); - - const db = getSqlite(); - - const refreshSchema = async () => setSchema(await db.schema()); - - useEffect(() => { - db.init().then((r) => { setPersisted(r.persisted); return refreshSchema(); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const run = async (only?: string) => { - const script = only ?? (editorRef.current?.getModel()?.getValueInRange(editorRef.current.getSelection()!) || sql); - setBusy(true); setError(''); setMessage(''); - try { - const res = await db.exec(script); - // Grids = statements that returned columns (SELECT/PRAGMA); the rest are - // DDL/DML and get a text summary built from each result's `kind`. - const withRows = res.results.filter((r) => r.columns.length > 0); - const noRows = res.results.filter((r) => r.columns.length === 0); - setGrids(withRows); - setActiveGrid(0); - const totalMs = res.results.reduce((s, r) => s + r.elapsedMs, 0).toFixed(1); - if (noRows.length) { - const ddl = noRows.filter((r) => r.kind === 'ddl').length; - const affected = noRows.filter((r) => r.kind === 'dml').reduce((s, r) => s + r.rowsAffected, 0); - const parts: string[] = []; - if (ddl) parts.push(`${ddl} schema change(s)`); - if (noRows.some((r) => r.kind === 'dml')) parts.push(`${affected} row(s) affected`); - setMessage(`${parts.join(' · ') || 'ok'} · ${totalMs} ms`); - } else if (withRows.length) { - setMessage(`${withRows[0].rows.length} row(s) · ${totalMs} ms`); - } - if (res.error) setError(res.error); - await refreshSchema(); - } catch (e) { - setError(e instanceof Error ? e.message : 'Query failed.'); - } finally { - setBusy(false); - } - }; - - const browseTable = async (name: string) => { - setBusy(true); setError(''); setMessage(''); - try { - const r = await db.tableRows(name, 200, 0); - setGrids([r]); setActiveGrid(0); - setMessage(`${name}: first ${r.rows.length} row(s)`); - } catch (e) { - setError(e instanceof Error ? e.message : 'Could not read table.'); - } finally { - setBusy(false); - } - }; - - const onMount = (editor: monaco.editor.IStandaloneCodeEditor) => { - editorRef.current = editor; - // Cmd/Ctrl+Enter runs the script (or selection). - editor.addAction({ - id: 'gwt-run-sql', - label: 'Run SQL', - keybindings: [/* CtrlCmd */ 2048 | /* Enter */ 3], - run: () => { void run(); }, - }); - }; - - const exportDb = async () => { - const bytes = await db.exportDb(); - await downloadService.download(new Blob([bytes], { type: 'application/x-sqlite3' }), 'playground.sqlite'); - }; - - const importDb = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.sqlite,.db,.sqlite3'; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return; - setBusy(true); - try { - await db.importDb(new Uint8Array(await file.arrayBuffer())); - await refreshSchema(); - setMessage(`Imported ${file.name}`); - setGrids([]); - } catch (e) { - setError(e instanceof Error ? e.message : 'Could not import database.'); - } finally { - setBusy(false); - } - }; - input.click(); - }; - - const loadSample = async () => { - setBusy(true); - await db.loadSample(); - await refreshSchema(); - setSql('SELECT a.name AS artist, al.title, al.year\nFROM albums al JOIN artists a ON a.id = al.artist_id\nORDER BY al.year;\n'); - setMessage('Sample database loaded.'); - setBusy(false); - }; - - const resetDb = async () => { - if (!confirm('Drop all tables in the playground database?')) return; - setBusy(true); - await db.reset(); - await refreshSchema(); - setGrids([]); setMessage('Database reset.'); - setBusy(false); - }; - - const grid = grids[activeGrid]; - - return ( -
- {!persisted && ( - - Your browser can't persist this database (no OPFS). It lives in memory — export to keep your data. - - )} - -
- - - - - -
- -
- {/* Schema explorer */} - - - {/* Editor + results */} -
- - - {error && {error}} - {message && !error &&

{message}

} - - {grids.length > 1 && ( -
- {grids.map((_, i) => ( - - ))} -
- )} - - {grid && grid.columns.length > 0 && ( -
-
- - - {grid.columns.map((c) => )} - - - {grid.rows.slice(0, 1000).map((row, ri) => ( - - {row.map((cell, ci) => ( - - ))} - - ))} - -
{c}
navigator.clipboard?.writeText(cell == null ? '' : String(cell))} - className="cursor-copy border-2 border-border px-2 py-1 font-mono"> - {cell == null ? NULL : String(cell)} -
-
-
- {grid.rows.length > 1000 && Showing first 1000 of {grid.rows.length} rows.} -
- - -
-
-
- )} -
-
-
- ); -} -``` - -- [ ] **Step 2: Confirm the `Alert` component supports `variant="warning"`** - -Run: `grep -nE "warning|variant" src/components/ui/Alert.tsx` -Expected: a `warning` variant exists. If it does not, change the `` to `` in the island (keep the copy) and note it in this checkbox. - -- [ ] **Step 3: Register the tool** - -In `src/registry/tools.ts`, add `Database` to the Lucide import line (if not already present), then add this entry after the `code-scratchpad` entry: - -```ts - { - id: 'sqlite-playground', - name: 'SQLite Playground', - category: 'Playground', - route: '/tools/sqlite-playground', - keywords: ['sqlite', 'sql', 'database', 'db', 'query', 'table', 'index', 'ddl', 'dml', 'playground'], - icon: Database, - summary: 'Run SQL against an on-device SQLite database', - load: () => import('@/islands/playground/SqlitePlayground'), - status: 'stable' - }, -``` - -- [ ] **Step 4: Build** - -Run: `npm run build 2>&1 | tail -2` -Expected: `[build] Complete!` with the page count up by 1 vs Task 6. - -- [ ] **Step 5: Headless-verify the full flow (create → insert → select grid → persist → export)** - -Run: - -```bash -npx astro preview --port 4353 > /tmp/pv.log 2>&1 & -sleep 4 -npm install -D puppeteer-core --legacy-peer-deps >/dev/null 2>&1 -cat > /tmp/dbcheck.mjs << 'EOF' -import puppeteer from 'puppeteer-core'; -const b = await puppeteer.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true, args: ['--no-sandbox'] }); -const p = await b.newPage(); -let errs=[]; p.on('pageerror', e=>errs.push(e.message.slice(0,160))); -await p.goto('http://localhost:4353/tools/sqlite-playground',{waitUntil:'networkidle2',timeout:30000}); -await new Promise(r=>setTimeout(r,1500)); -// Load the sample DB via its button, then run a query. -await p.evaluate(()=>[...document.querySelectorAll('button')].find(b=>/load sample/i.test(b.textContent))?.click()); -await new Promise(r=>setTimeout(r,1500)); -await p.evaluate(()=>[...document.querySelectorAll('button')].find(b=>/^run/i.test(b.textContent))?.click()); -await new Promise(r=>setTimeout(r,1200)); -const grid = await p.evaluate(()=>{ - const rows = document.querySelectorAll('table tbody tr').length; - const cols = document.querySelectorAll('table thead th').length; - const schema = [...document.querySelectorAll('aside button')].map(b=>b.textContent.trim()).join(','); - return { rows, cols, schema }; -}); -console.log('grid rows:', grid.rows, '| cols:', grid.cols, '| schema:', grid.schema); -console.log('pageerrors:', errs.length?errs.join(';'):'none'); -await b.close(); -EOF -node /tmp/dbcheck.mjs -kill %1 2>/dev/null -npm uninstall puppeteer-core --legacy-peer-deps >/dev/null 2>&1 -``` - -Expected: `grid rows: 5 | cols: 3` (the sample join returns 5 albums), `schema` lists `albums`/`artists`, and `pageerrors: none`. - -- [ ] **Step 6: Commit** - -```bash -git add src/islands/playground/SqlitePlayground.tsx src/registry/tools.ts -git commit -m "feat(playground): SQLite Playground UI (schema explorer, editor, results grid)" -``` - ---- - -## Task 8: Docs + final verification - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Add the Phase 9 section to README** - -Insert before the `## Testing` heading in `README.md`: - -```markdown -✅ **Phase 9 — Playground (2 tools, on-device dev sandboxes):** -- Code Scratchpad — a VS Code-grade **multi-file** editor on self-hosted - **Monaco**: native multi-cursor, move/copy line, column select, find & replace. - Open/save real files (File System Access API), autosaved to IndexedDB. -- SQLite Playground — a durable in-browser **SQLite** database - (`@sqlite.org/sqlite-wasm` + OPFS SAHPool, no COOP/COEP) with a schema explorer, - a SQL editor (⌘/Ctrl+Enter to run), and a **visual results grid**. DDL/DML show - a summary and refresh the schema; import/export `.sqlite`; a sample DB to explore. - -Both ride one lazily-loaded, self-hosted Monaco engine — never in the shell -payload. Nothing is uploaded. -``` - -- [ ] **Step 2: Run the full unit suite** - -Run: `npx vitest run 2>&1 | grep -E "Test Files|Tests "` -Expected: all pass (totals up by the 4 new lib test files). - -- [ ] **Step 3: Full production build** - -Run: `npm run build 2>&1 | tail -2` -Expected: `[build] Complete!` with 2 more pages than before Phase 9. - -- [ ] **Step 4: Confirm no staged wasm is committed** - -Run: `git status --short | grep -E "public/sqlite" && echo "ABORT: staged wasm" || echo "clean"` -Expected: `clean` (public/sqlite is gitignored). - -- [ ] **Step 5: Commit and push** - -```bash -git add README.md -git commit -m "docs(playground): document Phase 9 dev sandboxes" -git push origin develop -``` - ---- - -## Notes for the implementer - -- **Verification harness:** every UI/wasm task installs `puppeteer-core` with `--legacy-peer-deps`, runs against `astro preview`, then uninstalls it. Chrome path is `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` on this machine. Do NOT commit `puppeteer-core`. -- **Never commit `public/sqlite/`, `public/models/`, or other staged wasm** — they're gitignored and staged by `scripts/copy-wasm.mjs`/`stage-models.mjs`. -- **Monaco is the risk.** If Task 3 Step 7 fails, resolve it there (see the fallback note) before building any tool on top — exactly as the ffmpeg core issue was resolved before shipping Video→GIF. -- **Keybinding constant** in `SqlitePlayground.onMount`: `2048 | 3` is `monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter`; import `monaco` from `./monaco-setup` and use the named constants instead if you prefer clarity over avoiding the extra import. -``` diff --git a/docs/superpowers/plans/2026-07-14-tauri-desktop-app.md b/docs/superpowers/plans/2026-07-14-tauri-desktop-app.md deleted file mode 100644 index 51e2877..0000000 --- a/docs/superpowers/plans/2026-07-14-tauri-desktop-app.md +++ /dev/null @@ -1,1085 +0,0 @@ -# GoodWebTools Desktop App Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a Tauri 2 desktop application that wraps the existing GoodWebTools web app with OS-level capabilities (system capture, global hotkeys, desktop marquee, system audio, native FFmpeg) while refactoring all 55 tools to use a shell-agnostic service layer. - -**Architecture:** One codebase, two shells (web + Tauri). Service abstraction layer auto-detects the execution environment (`window.__TAURI__`) and routes to browser APIs or Rust IPC. All React components remain unchanged, importing from services instead of using browser APIs directly. Zero code duplication. - -**Tech Stack:** Tauri 2, Rust, TypeScript, React 18, Astro 4, ScreenCaptureKit (macOS), Windows.Graphics.Capture (Windows), PipeWire (Linux), GitHub Actions - -## Global Constraints - -- **Tauri version:** 2.x (latest stable) -- **Rust edition:** 2021 -- **TypeScript:** Strict mode enabled -- **Test coverage:** All services must have unit tests (browser + Tauri implementations) -- **Backwards compatibility:** Web app must continue working identically after refactoring -- **Platform support:** macOS 10.15+, Windows 10 1903+, Linux (Ubuntu 22.04+) -- **File paths:** All services in `src/services/*`, all Rust code in `src-tauri/src/*` -- **Commit frequency:** After each passing test (TDD cycle) -- **All 259 existing tests must pass** throughout refactoring - ---- - -## File Structure Overview - -### New TypeScript Files (Service Layer) -``` -src/services/ -├── platform/ -│ ├── index.ts # Shell detection utilities -│ └── types.ts # Platform types -├── capture/ -│ ├── index.ts # Auto-export browser or Tauri impl -│ ├── types.ts # CaptureService interface -│ ├── browser.ts # Browser implementation -│ ├── browser.test.ts # Browser tests -│ ├── tauri.ts # Tauri implementation -│ └── tauri.test.ts # Tauri tests -├── file/ -│ ├── index.ts -│ ├── types.ts -│ ├── browser.ts -│ ├── browser.test.ts -│ ├── tauri.ts -│ └── tauri.test.ts -├── download/ -│ ├── index.ts -│ ├── types.ts -│ ├── browser.ts -│ ├── browser.test.ts -│ ├── tauri.ts -│ └── tauri.test.ts -├── asset/ -│ ├── index.ts -│ ├── types.ts -│ ├── browser.ts -│ ├── browser.test.ts -│ ├── tauri.ts -│ └── tauri.test.ts -├── hotkey/ -│ ├── index.ts -│ ├── types.ts -│ ├── browser.ts -│ ├── browser.test.ts -│ ├── tauri.ts -│ └── tauri.test.ts -└── clipboard/ - ├── index.ts - ├── types.ts - ├── browser.ts - ├── browser.test.ts - ├── tauri.ts - └── tauri.test.ts -``` - -### New Rust Files (Tauri Backend) -``` -src-tauri/src/ -├── main.rs # App entry, window, tray -├── lib.rs # Re-exports -├── commands.rs # IPC command handlers -├── capture/ -│ ├── mod.rs -│ ├── macos.rs -│ ├── windows.rs -│ └── linux.rs -├── hotkeys.rs -├── overlay.rs -├── audio.rs -├── ffmpeg.rs -└── utils.rs -``` - -### New/Modified Astro Pages -``` -src/pages/ -├── download.astro # Desktop download page -├── settings.astro # Extended with desktop settings -└── first-run.astro # Permission wizard -``` - -### New Scripts & Config -``` -scripts/bundle-tauri-assets.mjs -.github/workflows/release.yml -worker/download-tracker.js -src-tauri/tauri.conf.json -src-tauri/Cargo.toml -``` - ---- - -## Task 1: Tauri Project Setup - -**Files:** -- Create: `src-tauri/Cargo.toml` -- Create: `src-tauri/tauri.conf.json` -- Create: `src-tauri/src/main.rs` -- Create: `src-tauri/src/lib.rs` -- Create: `src-tauri/build.rs` -- Modify: `package.json` - -**Interfaces:** -- Consumes: Existing Astro build output (`dist/`) -- Produces: `npm run tauri:dev` command that launches desktop app - -- [ ] **Step 1: Install Tauri CLI** - -```bash -npm install --save-dev @tauri-apps/cli@^2.0.0 -``` - -Expected: Package installed, added to devDependencies - -- [ ] **Step 2: Add Tauri scripts to package.json** - -```json -{ - "scripts": { - "tauri": "tauri", - "tauri:dev": "tauri dev", - "tauri:build": "tauri build", - "tauri:bundle": "npm run build && tauri build" - } -} -``` - -- [ ] **Step 3: Initialize Tauri project** - -```bash -npm run tauri init -``` - -When prompted: -- App name: `GoodWebTools` -- Window title: `GoodWebTools` -- Web assets location: `../dist` -- Dev server URL: `http://localhost:4321` -- Frontend dev command: `npm run dev` -- Frontend build command: `npm run build` - -Expected: `src-tauri/` directory created with boilerplate - -- [ ] **Step 4: Create Cargo.toml** - -```toml -[package] -name = "goodwebtools" -version = "1.0.0" -description = "Privacy-first client-side tools" -authors = ["Kresna "] -edition = "2021" - -[build-dependencies] -tauri-build = { version = "2", features = [] } - -[dependencies] -tauri = { version = "2", features = ["macos-private-api"] } -tauri-plugin-shell = "2" -tauri-plugin-dialog = "2" -tauri-plugin-fs = "2" -tauri-plugin-clipboard-manager = "2" -tauri-plugin-global-shortcut = "2" -tauri-plugin-updater = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["full"] } - -[target.'cfg(target_os = "macos")'.dependencies] -cocoa = "0.25" -objc = "0.2" - -[target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.54", features = ["Graphics_Capture", "Foundation"] } - -[target.'cfg(target_os = "linux")'.dependencies] -ashpd = "0.7" - -[features] -default = ["custom-protocol"] -custom-protocol = ["tauri/custom-protocol"] -``` - -- [ ] **Step 5: Create tauri.conf.json** - -```json -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "GoodWebTools", - "version": "1.0.0", - "identifier": "com.goodwebtools.app", - "build": { - "beforeBuildCommand": "npm run build", - "frontendDist": "../dist", - "devUrl": "http://localhost:4321" - }, - "app": { - "windows": [ - { - "title": "GoodWebTools", - "width": 1280, - "height": 800, - "minWidth": 800, - "minHeight": 600, - "resizable": true, - "fullscreen": false - } - ], - "security": { - "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'" - } - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "resources": [], - "category": "Utility", - "shortDescription": "Privacy-first client-side tools", - "longDescription": "GoodWebTools Desktop brings browser-based tools to your desktop with system-wide capture, global hotkeys, and native file access." - } -} -``` - -- [ ] **Step 6: Create main.rs** - -```rust -// src-tauri/src/main.rs -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} -``` - -- [ ] **Step 7: Create lib.rs** - -```rust -// src-tauri/src/lib.rs -// Re-exports will go here as we add modules -``` - -- [ ] **Step 8: Create build.rs** - -```rust -// src-tauri/build.rs -fn main() { - tauri_build::build() -} -``` - -- [ ] **Step 9: Test Tauri dev mode** - -```bash -npm run tauri:dev -``` - -Expected: Desktop window opens showing GoodWebTools homepage (localhost:4321) - -- [ ] **Step 10: Commit** - -```bash -git add src-tauri/ package.json package-lock.json -git commit -m "feat(tauri): initialize Tauri 2 project structure" -``` - ---- - -## Task 2: Platform Service (Shell Detection) - -**Files:** -- Create: `src/services/platform/types.ts` -- Create: `src/services/platform/index.ts` -- Create: `src/services/platform/platform.test.ts` - -**Interfaces:** -- Consumes: None -- Produces: `isTauri(): boolean`, `getPlatform(): Platform` functions for other services - -- [ ] **Step 1: Write test for shell detection** - -```typescript -// src/services/platform/platform.test.ts -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { isTauri, getPlatform, getArchitecture } from './index'; - -describe('Platform Service', () => { - let originalWindow: typeof window; - - beforeEach(() => { - originalWindow = global.window; - }); - - afterEach(() => { - global.window = originalWindow; - }); - - it('detects browser environment', () => { - // @ts-ignore - global.window = { __TAURI__: undefined }; - expect(isTauri()).toBe(false); - }); - - it('detects Tauri environment', () => { - // @ts-ignore - global.window = { __TAURI__: {} }; - expect(isTauri()).toBe(true); - }); - - it('returns correct platform', () => { - const platform = getPlatform(); - expect(['macos', 'windows', 'linux', 'unknown']).toContain(platform); - }); - - it('returns correct architecture', () => { - const arch = getArchitecture(); - expect(['x86_64', 'aarch64', 'unknown']).toContain(arch); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -npm test -- src/services/platform/platform.test.ts -``` - -Expected: FAIL - Module not found - -- [ ] **Step 3: Create types** - -```typescript -// src/services/platform/types.ts -export type Platform = 'macos' | 'windows' | 'linux' | 'unknown'; -export type Architecture = 'x86_64' | 'aarch64' | 'unknown'; - -export interface PlatformInfo { - platform: Platform; - architecture: Architecture; - isTauri: boolean; -} -``` - -- [ ] **Step 4: Implement platform detection** - -```typescript -// src/services/platform/index.ts -import type { Platform, Architecture, PlatformInfo } from './types'; - -export function isTauri(): boolean { - return typeof window !== 'undefined' && '__TAURI__' in window; -} - -export function getPlatform(): Platform { - if (typeof window === 'undefined') return 'unknown'; - - const ua = navigator.userAgent.toLowerCase(); - - if (ua.includes('mac')) return 'macos'; - if (ua.includes('win')) return 'windows'; - if (ua.includes('linux')) return 'linux'; - - return 'unknown'; -} - -export function getArchitecture(): Architecture { - if (typeof window === 'undefined') return 'unknown'; - - // @ts-ignore - navigator.userAgentData is experimental - const uaData = navigator.userAgentData; - - if (uaData && uaData.platform) { - if (uaData.platform === 'macOS' && navigator.platform === 'MacIntel') { - // Try to detect Apple Silicon - // This is a heuristic; proper detection requires Tauri API - return 'aarch64'; - } - } - - const ua = navigator.userAgent.toLowerCase(); - if (ua.includes('arm') || ua.includes('aarch64')) return 'aarch64'; - if (ua.includes('x86_64') || ua.includes('x64')) return 'x86_64'; - - return 'unknown'; -} - -export function getPlatformInfo(): PlatformInfo { - return { - platform: getPlatform(), - architecture: getArchitecture(), - isTauri: isTauri(), - }; -} - -export type { Platform, Architecture, PlatformInfo } from './types'; -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -npm test -- src/services/platform/platform.test.ts -``` - -Expected: PASS - All tests green - -- [ ] **Step 6: Commit** - -```bash -git add src/services/platform/ -git commit -m "feat(services): add platform detection service" -``` - ---- - -## Task 3: CaptureService Interface & Types - -**Files:** -- Create: `src/services/capture/types.ts` -- Create: `src/services/capture/index.ts` (shell detection only, no impls yet) - -**Interfaces:** -- Consumes: `isTauri()` from platform service -- Produces: `CaptureService` interface, `captureService` singleton export - -- [ ] **Step 1: Create CaptureService types** - -```typescript -// src/services/capture/types.ts -export interface Rectangle { - x: number; - y: number; - width: number; - height: number; -} - -export interface CaptureOptions { - format?: 'png' | 'jpeg'; - quality?: number; - includeAudio?: boolean; - systemAudio?: boolean; -} - -export interface RecordOptions { - format?: 'webm' | 'mp4'; - videoBitrate?: number; - audioBitrate?: number; - includeAudio?: boolean; - systemAudio?: boolean; - fps?: number; -} - -export interface RecordingHandle { - id: string; - startTime: number; -} - -export interface CaptureServiceCapabilities { - systemCapture: boolean; - regionSelector: boolean; - systemAudio: boolean; - globalHotkeys: boolean; -} - -export interface CaptureService { - captureScreen(options?: CaptureOptions): Promise; - captureWindow(windowId?: string): Promise; - captureRegion(bounds: Rectangle): Promise; - startRecording(options?: RecordOptions): Promise; - stopRecording(handle: RecordingHandle): Promise; - showRegionSelector(): Promise; - getCapabilities(): CaptureServiceCapabilities; -} -``` - -- [ ] **Step 2: Create service index with shell detection stub** - -```typescript -// src/services/capture/index.ts -import { isTauri } from '@/services/platform'; -import type { CaptureService } from './types'; - -let instance: CaptureService | null = null; - -async function getInstance(): Promise { - if (instance) return instance; - - if (isTauri()) { - const { TauriCaptureService } = await import('./tauri'); - instance = new TauriCaptureService(); - } else { - const { BrowserCaptureService } = await import('./browser'); - instance = new BrowserCaptureService(); - } - - return instance; -} - -// Synchronous export for convenience (loads lazily) -export const captureService = { - async captureScreen(options) { - const service = await getInstance(); - return service.captureScreen(options); - }, - async captureWindow(windowId) { - const service = await getInstance(); - return service.captureWindow(windowId); - }, - async captureRegion(bounds) { - const service = await getInstance(); - return service.captureRegion(bounds); - }, - async startRecording(options) { - const service = await getInstance(); - return service.startRecording(options); - }, - async stopRecording(handle) { - const service = await getInstance(); - return service.stopRecording(handle); - }, - async showRegionSelector() { - const service = await getInstance(); - return service.showRegionSelector(); - }, - getCapabilities() { - // This needs to be sync, so we'll handle it specially - if (isTauri()) { - return { - systemCapture: true, - regionSelector: true, - systemAudio: true, - globalHotkeys: true, - }; - } else { - return { - systemCapture: false, - regionSelector: false, - systemAudio: false, - globalHotkeys: false, - }; - } - }, -} as CaptureService; - -export type { - CaptureService, - CaptureOptions, - RecordOptions, - RecordingHandle, - Rectangle, - CaptureServiceCapabilities, -} from './types'; -``` - -- [ ] **Step 3: Verify TypeScript compiles** - -```bash -npm run build -``` - -Expected: No TypeScript errors - -- [ ] **Step 4: Commit** - -```bash -git add src/services/capture/types.ts src/services/capture/index.ts -git commit -m "feat(services): add CaptureService interface and types" -``` - ---- - -## Task 4: CaptureService Browser Implementation - -**Files:** -- Create: `src/services/capture/browser.ts` -- Create: `src/services/capture/browser.test.ts` - -**Interfaces:** -- Consumes: `CaptureService` interface from types.ts -- Produces: `BrowserCaptureService` class implementing browser-based capture - -- [ ] **Step 1: Write failing test for browser capture** - -```typescript -// src/services/capture/browser.test.ts -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BrowserCaptureService } from './browser'; - -describe('BrowserCaptureService', () => { - let service: BrowserCaptureService; - - beforeEach(() => { - service = new BrowserCaptureService(); - }); - - it('captures screen using getDisplayMedia', async () => { - const mockStream = new MediaStream(); - const mockTrack = { - stop: vi.fn(), - }; - mockStream.getTracks = vi.fn(() => [mockTrack]); - - vi.spyOn(navigator.mediaDevices, 'getDisplayMedia').mockResolvedValue(mockStream); - - // Mock canvas toBlob - HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => { - callback(new Blob(['test'], { type: 'image/png' })); - }); - - const blob = await service.captureScreen({ format: 'png' }); - - expect(blob).toBeInstanceOf(Blob); - expect(navigator.mediaDevices.getDisplayMedia).toHaveBeenCalled(); - expect(mockTrack.stop).toHaveBeenCalled(); - }); - - it('returns null for region selector (not supported)', async () => { - const result = await service.showRegionSelector(); - expect(result).toBeNull(); - }); - - it('returns correct capabilities', () => { - const caps = service.getCapabilities(); - expect(caps.systemCapture).toBe(false); - expect(caps.regionSelector).toBe(false); - expect(caps.systemAudio).toBe(false); - expect(caps.globalHotkeys).toBe(false); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -npm test -- src/services/capture/browser.test.ts -``` - -Expected: FAIL - Module not found - -- [ ] **Step 3: Implement BrowserCaptureService** - -```typescript -// src/services/capture/browser.ts -import type { - CaptureService, - CaptureOptions, - RecordOptions, - RecordingHandle, - Rectangle, - CaptureServiceCapabilities, -} from './types'; - -export class BrowserCaptureService implements CaptureService { - private activeRecordings = new Map(); - - async captureScreen(options?: CaptureOptions): Promise { - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: { mediaSource: 'screen' as any }, - audio: options?.includeAudio || false, - }); - - const video = document.createElement('video'); - video.srcObject = stream; - video.muted = true; - await video.play(); - - // Wait for video to be ready - await new Promise(resolve => { - video.onloadedmetadata = resolve; - }); - - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const ctx = canvas.getContext('2d'); - - if (!ctx) { - stream.getTracks().forEach(t => t.stop()); - throw new Error('Could not get canvas context'); - } - - ctx.drawImage(video, 0, 0); - stream.getTracks().forEach(t => t.stop()); - - return new Promise((resolve, reject) => { - canvas.toBlob( - blob => { - if (blob) { - resolve(blob); - } else { - reject(new Error('Could not create blob from canvas')); - } - }, - `image/${options?.format || 'png'}`, - options?.quality - ); - }); - } - - async captureWindow(_windowId?: string): Promise { - // Browser can't target specific windows, fall back to screen capture - return this.captureScreen(); - } - - async captureRegion(_bounds: Rectangle): Promise { - // Browser can't capture specific region without full screen first - throw new Error('Region capture not supported in browser. Use showRegionSelector() to check support.'); - } - - async startRecording(options?: RecordOptions): Promise { - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: { mediaSource: 'screen' as any }, - audio: options?.includeAudio || false, - }); - - const mimeType = options?.format === 'mp4' - ? 'video/mp4' - : 'video/webm'; - - const recorder = new MediaRecorder(stream, { - mimeType: mimeType, - videoBitsPerSecond: options?.videoBitrate, - audioBitsPerSecond: options?.audioBitrate, - }); - - const handle: RecordingHandle = { - id: `rec_${Date.now()}`, - startTime: Date.now(), - }; - - this.activeRecordings.set(handle.id, recorder); - recorder.start(); - - return handle; - } - - async stopRecording(handle: RecordingHandle): Promise { - const recorder = this.activeRecordings.get(handle.id); - - if (!recorder) { - throw new Error(`Recording ${handle.id} not found`); - } - - return new Promise((resolve, reject) => { - const chunks: Blob[] = []; - - recorder.ondataavailable = (event) => { - if (event.data.size > 0) { - chunks.push(event.data); - } - }; - - recorder.onstop = () => { - const blob = new Blob(chunks, { type: recorder.mimeType }); - this.activeRecordings.delete(handle.id); - - // Stop all tracks - recorder.stream.getTracks().forEach(t => t.stop()); - - resolve(blob); - }; - - recorder.onerror = (error) => { - this.activeRecordings.delete(handle.id); - reject(error); - }; - - recorder.stop(); - }); - } - - async showRegionSelector(): Promise { - // Not possible in browser - return null; - } - - getCapabilities(): CaptureServiceCapabilities { - return { - systemCapture: false, - regionSelector: false, - systemAudio: false, - globalHotkeys: false, - }; - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -npm test -- src/services/capture/browser.test.ts -``` - -Expected: PASS - All tests green - -- [ ] **Step 5: Test in browser** - -Create a temporary test page to verify browser capture works: - -```typescript -// Test manually in browser console: -import { captureService } from './services/capture'; -const blob = await captureService.captureScreen(); -console.log('Captured:', blob); -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/services/capture/browser.ts src/services/capture/browser.test.ts -git commit -m "feat(services): implement browser-based CaptureService" -``` - ---- - -*Due to the extensive scope of this project (55 tools, 11 phases, 6-8 weeks), I'll continue with the remaining tasks in a structured format. The pattern established above applies to all subsequent tasks.* - ---- - -## Task 5: Rust Capture Commands (macOS) - -**Files:** -- Create: `src-tauri/src/commands.rs` -- Create: `src-tauri/src/capture/mod.rs` -- Create: `src-tauri/src/capture/macos.rs` -- Modify: `src-tauri/src/main.rs` -- Modify: `src-tauri/src/lib.rs` - -**Interfaces:** -- Consumes: CaptureOptions from frontend -- Produces: `capture_screen` Tauri command returning Vec - -*[Steps follow TDD pattern: write Rust test, run cargo test, implement, verify, commit]* - ---- - -## Task 6-10: Complete Service Layer - -Following the same TDD pattern, implement: - -- **Task 6:** FileService (browser + Tauri) -- **Task 7:** DownloadService (browser + Tauri) -- **Task 8:** AssetService (browser + Tauri) -- **Task 9:** HotkeyService (browser + Tauri) -- **Task 10:** ClipboardService (browser + Tauri) - -Each task includes: -- TypeScript interface definition -- Browser implementation with tests -- Tauri implementation with tests -- Rust IPC commands -- Integration tests -- Commit after each passing test - ---- - -## Task 11-13: Tool Refactoring (High-Value Tools) - -**Task 11: Refactor Screenshot tool** -**Task 12: Refactor Screen Recorder tool** -**Task 13: Refactor Code Scratchpad tool** - -Pattern for each: -1. Write integration test that uses service -2. Refactor tool to import from service layer -3. Verify tool works in browser (regression test) -4. Verify tool works in Tauri -5. Commit - ---- - -## Task 14-20: Bulk Tool Refactoring - -Refactor remaining 52 tools in batches: -- **Task 14:** PDF tools (10 tools) -- **Task 15:** Media converters (5 tools) -- **Task 16:** File tools (4 tools) -- **Task 17:** Image tools (14 tools) -- **Task 18:** Dev tools (17 tools) -- **Task 19:** Drawing tools (2 tools) -- **Task 20:** Playground tools (2 tools - SQLite, Whiteboard) - ---- - -## Task 21: Desktop Region Selector Overlay - -**Files:** -- Create: `src-tauri/src/overlay.rs` -- Create: `src/pages/overlay.astro` (transparent selection UI) -- Modify: `src-tauri/src/commands.rs` - -Implements transparent fullscreen overlay for marquee region selection. - ---- - -## Task 22: Global Hotkey Registration - -**Files:** -- Create: `src-tauri/src/hotkeys.rs` -- Modify: `src-tauri/src/commands.rs` -- Modify: `src-tauri/src/main.rs` - ---- - -## Task 23: System Audio Capture - -**Files:** -- Create: `src-tauri/src/audio.rs` -- Create: `src-tauri/src/audio/macos.rs` -- Create: `src-tauri/src/audio/windows.rs` -- Create: `src-tauri/src/audio/linux.rs` - ---- - -## Task 24: Native FFmpeg Integration - -**Files:** -- Create: `src-tauri/src/ffmpeg.rs` -- Create: `scripts/download-ffmpeg-binaries.mjs` -- Modify: `src-tauri/tauri.conf.json` (add ffmpeg to resources) - ---- - -## Task 25: First-Run Permission Wizard - -**Files:** -- Create: `src/pages/first-run.astro` -- Create: `src/islands/FirstRunWizard.tsx` -- Modify: `src-tauri/src/commands.rs` (add permission check commands) - ---- - -## Task 26: Settings Page Enhancements - -**Files:** -- Modify: `src/pages/settings.astro` -- Create: `src/islands/settings/DesktopSettings.tsx` -- Create: `src/islands/settings/PermissionStatus.tsx` - ---- - -## Task 27: System Tray Integration - -**Files:** -- Modify: `src-tauri/src/main.rs` (add system tray setup) -- Create: `src-tauri/src/tray.rs` - ---- - -## Task 28: Auto-Updater - -**Files:** -- Modify: `src-tauri/Cargo.toml` (add tauri-plugin-updater) -- Modify: `src-tauri/tauri.conf.json` (configure updater) -- Create: `src/islands/UpdateChecker.tsx` - ---- - -## Task 29: Asset Bundling Script - -**Files:** -- Create: `scripts/bundle-tauri-assets.mjs` -- Modify: `package.json` (add prebuild script) - ---- - -## Task 30: Download Page & Tracking Endpoint - -**Files:** -- Create: `src/pages/download.astro` -- Create: `worker/download-tracker.js` -- Modify: `wrangler.jsonc` (add download route) - ---- - -## Task 31: GitHub Actions Release Workflow - -**Files:** -- Create: `.github/workflows/release.yml` - ---- - -## Task 32: Cross-Platform Testing & Polish - -**Testing checklist:** -- Run all 259 existing tests -- Manual test on macOS, Windows, Linux -- Permission flows on all platforms -- Regression test: verify web app unchanged -- Performance: service layer overhead <50ms - ---- - -## Task 33: Beta Release Preparation - -**Files:** -- Create: `CHANGELOG.md` -- Update: `README.md` (add desktop download section) -- Tag: `desktop-v1.0.0-beta.1` - ---- - -## Execution Notes - -**Estimated Timeline:** -- Tasks 1-10 (Foundation & Services): 2 weeks -- Tasks 11-20 (Tool Refactoring): 3 weeks -- Tasks 21-28 (Desktop Features): 2 weeks -- Tasks 29-33 (Release Pipeline & Testing): 1 week -- **Total: 8 weeks** - -**Testing Strategy:** -- Unit tests after every service implementation -- Integration tests after tool refactoring -- Manual E2E tests on all platforms before beta -- All 259 existing tests must pass throughout - -**Risk Mitigation:** -- Test on all platforms continuously (not just at end) -- Keep web app functional throughout refactoring -- Each task produces independently testable output - ---- - -## Self-Review Checklist - -**Spec Coverage:** -✅ Service layer (all 6 services) -✅ Tool refactoring (all 55 tools) -✅ Native capabilities (capture, hotkeys, marquee, audio, FFmpeg) -✅ Desktop features (Settings, tray, wizard, updater) -✅ Build pipeline (GitHub Actions, asset bundling) -✅ Download page & tracking -✅ Cross-platform support (macOS, Windows, Linux) -✅ Testing strategy -✅ Error handling (permission flows) - -**Placeholder Check:** -✅ No TBD/TODO markers -✅ All task steps include actual code (where applicable) -✅ Exact file paths specified -✅ Commands with expected outputs - -**Type Consistency:** -✅ CaptureService interface used consistently -✅ Service pattern repeated for all 6 services -✅ Platform detection utilities reused - -**Gaps:** -None identified - all spec requirements covered - ---- - -**Plan Complete** - -Total Tasks: 33 -Estimated Duration: 8 weeks -Branch: `feat/tauri-desktop-app` diff --git a/docs/superpowers/plans/2026-07-20-optimize-desktop-screenshot.md b/docs/superpowers/plans/2026-07-20-optimize-desktop-screenshot.md deleted file mode 100644 index 33d763d..0000000 --- a/docs/superpowers/plans/2026-07-20-optimize-desktop-screenshot.md +++ /dev/null @@ -1,103 +0,0 @@ -# Optimize Desktop Screenshot Tool — Implementation Plan - -> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax. Each phase is an -> isolated commit that builds clean and keeps all automated tests green. - -**Goal:** Make the desktop region-screenshot flow feel instant by eliminating -window-instantiation delay, IPC serialization overhead, and capture-before-show -latency — following the "pre-warmed architecture" pattern (Lark/Electron-style). - -**Approved scope:** Phases A + B + C + D (all four). - -**Architecture:** Pre-warm the region-selector window at startup and reuse it via -show/hide. Show it *first* (instant crosshair), then capture natively in a -background thread and inject the frozen background via a Tauri event + the asset -protocol (no base64/localStorage). Crop the selected region natively and return -only those bytes as a raw `tauri::ipc::Response` (no `number[]` JSON). - -**Tech Stack:** Tauri 2, Rust (core-graphics), Astro overlay page, React island. - -## Global Constraints -- Every phase must `cargo build` clean (0 errors) and keep `npm test` at 385+ green. -- Pure logic (crop math, asset-path building) gets Rust/TS unit tests — the live - capture + window timing require **manual hardware smoke-testing** (called out per phase). -- Separate commit per phase so any phase can be reverted independently if a - hardware test fails. -- The browser (non-Tauri) screenshot path must remain unchanged. - ---- - -## ⚠️ Risk register (from this repo's git history) -- obs **8630**: an event-based screenshot-background approach was tried and - **abandoned** in favor of a semi-transparent overlay. Phase C revives an - event-based background — treat as the highest-risk phase; keep the localStorage - fallback path until hardware-verified. -- obs **8199**: prior "window reuse → wrong display" bug. Phase A must reposition + - resize the reused window on every show and assert the target display bounds. -- obs **8462 / 8981**: OS-level transparency caused a Cocoa `setOpaque_` crash and - was removed from the countdown. Phase A/C transparency must be validated on macOS. - ---- - -## Phase A — Pre-warm & reuse the region-selector window - -**Files:** `src-tauri/src/main.rs`, `src-tauri/src/overlay.rs`, `src/pages/overlay.astro` - -- Pre-create `region-selector` hidden in `main.rs` setup (decorations off, - always-on-top, skip-taskbar, transparent, visible:false). -- `show_region_selector`: if the window exists, reposition to the target display - bounds + resize + `.show()` + focus; only build as a fallback. -- `close_region_selector`: `.hide()` instead of `.close()` (keep it warm). -- `overlay.astro`: move init logic into a re-runnable `initOverlay()` and call it - both on load *and* on a new `overlay-show` Tauri event (reset selection each show). - -**Manual test:** trigger region screenshot twice; crosshair appears fast the 2nd -time; correct display; selection resets between uses. - -## Phase B — Raw-bytes IPC + asset-protocol background - -**Files:** `src-tauri/src/commands.rs`, `src/services/capture/tauri.ts`, -`src-tauri/src/overlay.rs` (temp-file write), `src/pages/overlay.astro` - -- `capture_screen` / `capture_region` return `tauri::ipc::Response::new(bytes)`. -- `capture/tauri.ts`: read the response as `ArrayBuffer` → `Blob` (no `number[]`). -- Overlay background: write the downscaled JPEG to a temp file; overlay loads it - via `convertFileSrc()` instead of base64→localStorage. - -**Manual test:** capture on a 4K/5K display; no multi-second freeze; background -renders correctly. - -## Phase C — Two-phase instant reveal - -**Files:** `src-tauri/src/commands.rs` (new `trigger_region_capture`), -`src/pages/overlay.astro`, `src/islands/media/Screenshot.tsx` - -- New command shows the pre-warmed window *immediately*, then - `tauri::async_runtime::spawn` captures + writes the bg temp file and - `emit('overlay-show', { displayId, bgPath })`. -- Overlay shows crosshair on transparent bg first; swaps in bg on the event. -- Keep localStorage fallback until hardware-verified. - -**Manual test:** crosshair appears effectively instantly; background fills in a -beat later with no white flash. - -## Phase D — Native server-side crop - -**Files:** `src-tauri/src/commands.rs`, `src/pages/overlay.astro` / -`src/islands/media/Screenshot.tsx` - -- Hold the full-res capture in an in-memory `Mutex>` - keyed by capture id (set in Phase C's spawn). -- New `crop_capture(captureId, region)` crops natively (physical-pixel math moved - server-side) → returns raw bytes via `tauri::ipc::Response`. -- Frontend stops shipping the full-res screen to JS; it receives only the crop. -- Unit-test the physical-pixel crop math (pure Rust fn). - -**Manual test:** small-region grab on a HiDPI display is pixel-accurate and fast. - ---- - -## Verification -- After each phase: `cargo build` (0 errors) + `npm test -- --run` (385+ green) + - `cargo test` for any new pure-logic tests. -- Final: full manual smoke test on macOS (primary), note Windows/Linux as untested. diff --git a/docs/superpowers/plans/2026-07-25-dbdiagram-tool.md b/docs/superpowers/plans/2026-07-25-dbdiagram-tool.md deleted file mode 100644 index 72bcaba..0000000 --- a/docs/superpowers/plans/2026-07-25-dbdiagram-tool.md +++ /dev/null @@ -1,1475 +0,0 @@ -# DB Diagram Tool Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A dbdiagram.io-style tool (Draw category) where you write DBML in a code editor and see a live, interactive ER diagram with bold hover-highlight of relationships, plus SQL export to six dialects and image export to PNG/JPEG/WebP/SVG. - -**Architecture:** One React island with a Monaco (plain-text) DBML editor and an `@xyflow/react` ER diagram, plus a toolbar. DBML is the source of truth: on debounced edit it's parsed with `@dbml/core`, mapped to nodes/edges, auto-laid-out with `@dagrejs/dagre` (only for tables without a saved position), and rendered. DBML text + dragged node positions persist to IndexedDB. The island mirrors the existing Whiteboard's dynamic-import + expand/hide-navbar + IndexedDB patterns. - -**Tech Stack:** `@dbml/core` (parse + native SQL export), `@xyflow/react` v12 (interactive diagram), `@dagrejs/dagre` (layout), `html-to-image` (image export), self-hosted Monaco (already in app), `idb`, React 18, Vitest + jsdom. - -## Global Constraints - -- **Zero external network requests at runtime** — all four new deps bundled; Monaco already self-hosted; no CDN. -- **All client-side** — no server round-trips. -- Follow existing conventions: `ToolDef` entry (`src/types/tool.ts`), island default-export (no required props), Whiteboard-style dynamic import for heavy browser-only deps, IndexedDB via `idb` (mirror `src/tools/draw/whiteboard.store.ts`). -- Tool: **category `Draw`**, route `/tools/db-diagram`, `status: 'beta'`. -- Native SQL export dialects (via `@dbml/core`): `postgres`, `mysql`, `mssql`, `oracle`. Custom-generated dialects: `sqlite`, `clickhouse`. -- **Model-shape note for the implementer:** `@dbml/core`'s parsed `Database` object shape varies slightly across versions. Every `.lib` task below is test-first with **real DBML** — run the test, and if a field accessor (e.g. `field.not_null` vs `field.notNull`, `endpoint.fieldNames` vs `endpoint.fields`) doesn't match the installed version, `console.log(JSON.stringify(db.schemas[0], null, 2))` once, adjust the accessor, and keep the asserted **output contract** unchanged. The tests pin behavior, not library internals. - ---- - -## File Structure - -``` -package.json (modify — add 4 deps) -src/tools/draw/dbml.lib.ts (+ .test.ts) (new — parseDbml + buildFlow) -src/tools/draw/layout.lib.ts (+ .test.ts) (new — dagre auto-layout) -src/tools/draw/sql-export.lib.ts (+ .test.ts) (new — dialect SQL export) -src/tools/draw/diagram-image.lib.ts (+ .test.ts) (new — image export mapping + export) -src/tools/draw/dbdiagram.store.ts (new — IndexedDB load/save) -src/islands/draw/DbDiagram.tsx (new — the island) -src/islands/draw/db-diagram/TableNode.tsx (new — custom react-flow node) -src/islands/draw/db-diagram/RelationEdge.tsx (new — custom react-flow edge) -src/registry/tools.ts (modify — 1 entry) -``` - ---- - -## Task 1: Add dependencies - -**Files:** -- Modify: `package.json` - -- [ ] **Step 1: Install the four runtime deps** - -Run: -```bash -npm install --legacy-peer-deps @dbml/core @xyflow/react @dagrejs/dagre html-to-image -``` -Expected: all four appear in `package.json` dependencies; install succeeds. - -- [ ] **Step 2: Verify the app still builds with them present** - -Run: `npm run build` -Expected: build succeeds (deps present but not yet imported anywhere). - -- [ ] **Step 3: Commit** - -```bash -git add package.json package-lock.json -git commit -m "chore(deps): add @dbml/core, @xyflow/react, @dagrejs/dagre, html-to-image" -``` - ---- - -## Task 2: DBML parsing & flow model - -**Files:** -- Create: `src/tools/draw/dbml.lib.ts` -- Test: `src/tools/draw/dbml.lib.test.ts` - -**Interfaces:** -- Produces: - - `type TableColumn = { name: string; type: string; pk: boolean; fk: boolean; notNull: boolean; unique: boolean }` - - `type DiagramNode = { id: string; type: 'table'; data: { name: string; columns: TableColumn[] } }` - - `type DiagramEdge = { id: string; source: string; target: string; sourceHandle: string; targetHandle: string; data: { relation: string } }` - - `parseDbml(source: string): { db: unknown | null; error: string | null }` - - `buildFlow(db: unknown): { nodes: DiagramNode[]; edges: DiagramEdge[] }` - -- [ ] **Step 1: Write the failing test with real DBML** - -Create `src/tools/draw/dbml.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { parseDbml, buildFlow } from './dbml.lib'; - -const SCHEMA = ` -Table users { - id int [pk, increment] - email varchar [not null, unique] -} -Table posts { - id int [pk] - user_id int - title varchar -} -Ref: posts.user_id > users.id -`; - -describe('parseDbml', () => { - it('parses valid DBML without error', () => { - const { db, error } = parseDbml(SCHEMA); - expect(error).toBeNull(); - expect(db).not.toBeNull(); - }); - it('reports an error for invalid DBML without throwing', () => { - const { db, error } = parseDbml('Table {{{ broken'); - expect(db).toBeNull(); - expect(error).toBeTruthy(); - }); - it('treats empty input as empty, not an error', () => { - const { db, error } = parseDbml(''); - expect(error).toBeNull(); - // buildFlow on empty is asserted below - expect(buildFlow(db)).toEqual({ nodes: [], edges: [] }); - }); -}); - -describe('buildFlow', () => { - it('maps tables to nodes with column flags', () => { - const { db } = parseDbml(SCHEMA); - const { nodes } = buildFlow(db); - expect(nodes.map((n) => n.id).sort()).toEqual(['posts', 'users']); - const users = nodes.find((n) => n.id === 'users')!; - const id = users.data.columns.find((c) => c.name === 'id')!; - expect(id.pk).toBe(true); - const email = users.data.columns.find((c) => c.name === 'email')!; - expect(email.notNull).toBe(true); - expect(email.unique).toBe(true); - }); - it('maps a ref to an edge with column-level handles', () => { - const { db } = parseDbml(SCHEMA); - const { edges } = buildFlow(db); - expect(edges).toHaveLength(1); - const e = edges[0]; - // FK side is posts.user_id, PK side is users.id (orientation-independent check) - const endpoints = [`${e.source}.${e.sourceHandle}`, `${e.target}.${e.targetHandle}`].sort(); - expect(endpoints).toEqual(['posts.user_id', 'users.id']); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npm test -- --run src/tools/draw/dbml.lib.test.ts` -Expected: FAIL — cannot resolve `./dbml.lib`. - -- [ ] **Step 3: Write dbml.lib** - -Create `src/tools/draw/dbml.lib.ts`. Written against the `@dbml/core` class-instance model (`db.schemas[].tables[].fields[]`, `schema.refs[].endpoints[]`); adjust accessors if the installed version differs (see the model-shape note in Global Constraints), keeping the asserted output contract. - -```ts -import { Parser } from '@dbml/core'; - -export interface TableColumn { - name: string; - type: string; - pk: boolean; - fk: boolean; - notNull: boolean; - unique: boolean; -} -export interface DiagramNode { - id: string; - type: 'table'; - data: { name: string; columns: TableColumn[] }; -} -export interface DiagramEdge { - id: string; - source: string; - target: string; - sourceHandle: string; - targetHandle: string; - data: { relation: string }; -} - -/** Parse DBML text. Never throws — returns { db, error }. Empty input => { db:null, error:null }. */ -export function parseDbml(source: string): { db: unknown | null; error: string | null } { - if (!source.trim()) return { db: null, error: null }; - try { - const db = new Parser().parse(source, 'dbml'); - return { db, error: null }; - } catch (e) { - const err = e as { message?: string; diags?: { message: string; location?: { start?: { line?: number } } }[] }; - // @dbml/core throws a CompilerError with a `diags` array; surface the first. - const first = err.diags?.[0]; - const line = first?.location?.start?.line; - const msg = first?.message ?? err.message ?? 'Invalid DBML'; - return { db: null, error: line ? `Line ${line}: ${msg}` : msg }; - } -} - -// Loose shapes for the parsed model (see model-shape note). -interface RawField { name: string; type?: { type_name?: string }; pk?: boolean; not_null?: boolean; unique?: boolean; increment?: boolean } -interface RawTable { name: string; fields?: RawField[] } -interface RawEndpoint { tableName?: string; fieldNames?: string[]; fields?: { name: string }[]; relation?: string } -interface RawRef { endpoints?: RawEndpoint[] } -interface RawSchema { tables?: RawTable[]; refs?: RawRef[] } -interface RawDb { schemas?: RawSchema[] } - -const endpointField = (ep: RawEndpoint): string => ep.fieldNames?.[0] ?? ep.fields?.[0]?.name ?? ''; - -/** Map a parsed Database into react-flow nodes and edges. Safe on null. */ -export function buildFlow(db: unknown): { nodes: DiagramNode[]; edges: DiagramEdge[] } { - const schemas = (db as RawDb | null)?.schemas ?? []; - const nodes: DiagramNode[] = []; - const edges: DiagramEdge[] = []; - const fkColumns = new Set(); // `${table}.${column}` marked as FK - - // First pass: collect FK columns from refs so we can flag them on the nodes. - for (const schema of schemas) { - for (const ref of schema.refs ?? []) { - const eps = ref.endpoints ?? []; - for (const ep of eps) fkColumns.add(`${ep.tableName}.${endpointField(ep)}`); - } - } - - for (const schema of schemas) { - for (const table of schema.tables ?? []) { - nodes.push({ - id: table.name, - type: 'table', - data: { - name: table.name, - columns: (table.fields ?? []).map((f) => ({ - name: f.name, - type: f.type?.type_name ?? '', - pk: !!f.pk, - fk: fkColumns.has(`${table.name}.${f.name}`), - notNull: !!f.not_null, - unique: !!f.unique, - })), - }, - }); - } - (schema.refs ?? []).forEach((ref, i) => { - const [a, b] = ref.endpoints ?? []; - if (!a || !b) return; - // Orient FK (many) -> PK (one): the '*' side is the FK source. - const fkFirst = a.relation === '*' || b.relation === '1'; - const src = fkFirst ? a : b; - const dst = fkFirst ? b : a; - edges.push({ - id: `ref-${src.tableName}-${endpointField(src)}-${dst.tableName}-${endpointField(dst)}-${i}`, - source: src.tableName ?? '', - target: dst.tableName ?? '', - sourceHandle: endpointField(src), - targetHandle: endpointField(dst), - data: { relation: `${a.relation ?? ''}-${b.relation ?? ''}` }, - }); - }); - } - return { nodes, edges }; -} -``` - -- [ ] **Step 4: Run to verify it passes (adjust accessors if needed)** - -Run: `npm test -- --run src/tools/draw/dbml.lib.test.ts` -Expected: PASS. If any assertion fails on a flag (e.g. `notNull`), add a one-time `console.log(JSON.stringify((parseDbml(SCHEMA).db as any).schemas[0].tables[0].fields[0]))` to the test, inspect the real field names, update the `RawField` accessors, and re-run. Remove the log once green. - -- [ ] **Step 5: Commit** - -```bash -git add src/tools/draw/dbml.lib.ts src/tools/draw/dbml.lib.test.ts -git commit -m "feat(dbdiagram): parse DBML and build react-flow nodes/edges" -``` - ---- - -## Task 3: Auto-layout with dagre - -**Files:** -- Create: `src/tools/draw/layout.lib.ts` -- Test: `src/tools/draw/layout.lib.test.ts` - -**Interfaces:** -- Consumes: `DiagramNode`, `DiagramEdge` (Task 2); `@dagrejs/dagre`. -- Produces: - - `type PositionedNode = DiagramNode & { position: { x: number; y: number } }` - - `layoutNodes(nodes: DiagramNode[], edges: DiagramEdge[], saved: Record): PositionedNode[]` - -- [ ] **Step 1: Write the failing test** - -Create `src/tools/draw/layout.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { layoutNodes } from './layout.lib'; -import type { DiagramNode, DiagramEdge } from './dbml.lib'; - -const node = (id: string, cols = 2): DiagramNode => ({ - id, - type: 'table', - data: { name: id, columns: Array.from({ length: cols }, (_, i) => ({ name: `c${i}`, type: 'int', pk: i === 0, fk: false, notNull: false, unique: false })) }, -}); - -describe('layoutNodes', () => { - const nodes = [node('a'), node('b')]; - const edges: DiagramEdge[] = [{ id: 'e', source: 'a', target: 'b', sourceHandle: 'c0', targetHandle: 'c0', data: { relation: '*-1' } }]; - - it('keeps saved positions and computes the rest', () => { - const out = layoutNodes(nodes, edges, { a: { x: 500, y: 500 } }); - expect(out).toHaveLength(2); - expect(out.find((n) => n.id === 'a')!.position).toEqual({ x: 500, y: 500 }); - expect(out.find((n) => n.id === 'b')!.position).toBeDefined(); - }); - it('gives unsaved nodes distinct positions', () => { - const out = layoutNodes(nodes, edges, {}); - const [a, b] = ['a', 'b'].map((id) => out.find((n) => n.id === id)!.position); - expect(a.x !== b.x || a.y !== b.y).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npm test -- --run src/tools/draw/layout.lib.test.ts` -Expected: FAIL — cannot resolve `./layout.lib`. - -- [ ] **Step 3: Write layout.lib** - -Create `src/tools/draw/layout.lib.ts`: - -```ts -import dagre from '@dagrejs/dagre'; -import type { DiagramNode, DiagramEdge } from './dbml.lib'; - -export type PositionedNode = DiagramNode & { position: { x: number; y: number } }; - -const COL_HEIGHT = 26; -const HEADER = 40; -const NODE_WIDTH = 220; - -/** Position nodes: saved positions win; the rest are laid out left-to-right with dagre. */ -export function layoutNodes( - nodes: DiagramNode[], - edges: DiagramEdge[], - saved: Record, -): PositionedNode[] { - const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 80 }); - g.setDefaultEdgeLabel(() => ({})); - for (const n of nodes) { - g.setNode(n.id, { width: NODE_WIDTH, height: HEADER + n.data.columns.length * COL_HEIGHT }); - } - for (const e of edges) { - if (nodes.some((n) => n.id === e.source) && nodes.some((n) => n.id === e.target)) { - g.setEdge(e.source, e.target); - } - } - dagre.layout(g); - return nodes.map((n) => { - if (saved[n.id]) return { ...n, position: saved[n.id] }; - const p = g.node(n.id); - // dagre centers nodes; shift to top-left origin for react-flow. - return { ...n, position: { x: Math.round(p.x - NODE_WIDTH / 2), y: Math.round(p.y - (HEADER + n.data.columns.length * COL_HEIGHT) / 2) } }; - }); -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `npm test -- --run src/tools/draw/layout.lib.test.ts` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/tools/draw/layout.lib.ts src/tools/draw/layout.lib.test.ts -git commit -m "feat(dbdiagram): dagre auto-layout preserving saved node positions" -``` - ---- - -## Task 4: SQL export (native + custom dialects) - -**Files:** -- Create: `src/tools/draw/sql-export.lib.ts` -- Test: `src/tools/draw/sql-export.lib.test.ts` - -**Interfaces:** -- Consumes: `@dbml/core` (`exporter.export`, `Parser`); `parseDbml` (Task 2). -- Produces: - - `type Dialect = 'postgres' | 'mysql' | 'mssql' | 'oracle' | 'sqlite' | 'clickhouse'` - - `exportSql(source: string, dialect: Dialect): string` - - `const DIALECTS: { key: Dialect; label: string }[]` - -- [ ] **Step 1: Write the failing test** - -Create `src/tools/draw/sql-export.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { exportSql } from './sql-export.lib'; - -const SCHEMA = ` -Table users { - id int [pk, increment] - email varchar [not null] -} -Table posts { - id int [pk] - user_id int -} -Ref: posts.user_id > users.id -`; - -describe('exportSql — native dialects', () => { - it('postgres emits CREATE TABLE and a foreign key', () => { - const sql = exportSql(SCHEMA, 'postgres'); - expect(sql).toMatch(/create table/i); - expect(sql).toMatch(/foreign key|references/i); - }); -}); - -describe('exportSql — sqlite (custom)', () => { - it('uses SQLite affinities, AUTOINCREMENT, and no schema prefix', () => { - const sql = exportSql(SCHEMA, 'sqlite'); - expect(sql).toMatch(/create table\s+"?users"?/i); - expect(sql).toMatch(/integer/i); - expect(sql).toMatch(/autoincrement/i); - expect(sql).not.toMatch(/public\./i); - expect(sql).toMatch(/foreign key/i); - }); -}); - -describe('exportSql — clickhouse (custom)', () => { - it('uses MergeTree and ORDER BY, and omits foreign keys', () => { - const sql = exportSql(SCHEMA, 'clickhouse'); - expect(sql).toMatch(/engine\s*=\s*mergetree/i); - expect(sql).toMatch(/order by/i); - expect(sql).not.toMatch(/foreign key/i); - }); -}); - -describe('exportSql — errors', () => { - it('throws on an unknown dialect', () => { - // @ts-expect-error deliberately invalid - expect(() => exportSql(SCHEMA, 'db2')).toThrow(); - }); - it('throws on invalid DBML', () => { - expect(() => exportSql('Table {{{', 'sqlite')).toThrow(); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npm test -- --run src/tools/draw/sql-export.lib.test.ts` -Expected: FAIL — cannot resolve `./sql-export.lib`. - -- [ ] **Step 3: Write sql-export.lib** - -Create `src/tools/draw/sql-export.lib.ts`: - -```ts -import { exporter, Parser } from '@dbml/core'; -import { parseDbml } from './dbml.lib'; - -export type Dialect = 'postgres' | 'mysql' | 'mssql' | 'oracle' | 'sqlite' | 'clickhouse'; - -export const DIALECTS: { key: Dialect; label: string }[] = [ - { key: 'postgres', label: 'PostgreSQL' }, - { key: 'mysql', label: 'MySQL' }, - { key: 'mssql', label: 'SQL Server' }, - { key: 'oracle', label: 'Oracle' }, - { key: 'sqlite', label: 'SQLite' }, - { key: 'clickhouse', label: 'ClickHouse' }, -]; - -const NATIVE = new Set(['postgres', 'mysql', 'mssql', 'oracle']); - -/** Export DBML source to SQL for the given dialect. Throws on invalid DBML or unknown dialect. */ -export function exportSql(source: string, dialect: Dialect): string { - if (!DIALECTS.some((d) => d.key === dialect)) throw new Error(`Unknown dialect: ${dialect}`); - if (NATIVE.has(dialect)) { - // exporter.export takes DBML text directly and throws on parse errors. - return exporter.export(source, dialect); - } - const { db, error } = parseDbml(source); - if (error || !db) throw new Error(error ?? 'Invalid DBML'); - return dialect === 'sqlite' ? generateSqlite(db) : generateClickhouse(db); -} - -// ---- Custom generators (shapes per the model-shape note in Global Constraints) ---- -interface RawField { name: string; type?: { type_name?: string }; pk?: boolean; not_null?: boolean; unique?: boolean; increment?: boolean } -interface RawTable { name: string; fields?: RawField[] } -interface RawEndpoint { tableName?: string; fieldNames?: string[]; fields?: { name: string }[]; relation?: string } -interface RawRef { endpoints?: RawEndpoint[] } -interface RawSchema { tables?: RawTable[]; refs?: RawRef[] } - -const epField = (ep: RawEndpoint) => ep.fieldNames?.[0] ?? ep.fields?.[0]?.name ?? ''; -const schemasOf = (db: unknown): RawSchema[] => ((db as { schemas?: RawSchema[] }).schemas ?? []); - -/** Map a DBML column type to a SQLite affinity. */ -function sqliteType(t: string): string { - const s = t.toLowerCase(); - if (/int|serial/.test(s)) return 'INTEGER'; - if (/char|text|clob|uuid|json|date|time/.test(s)) return 'TEXT'; - if (/real|floa|doub|dec|num/.test(s)) return 'REAL'; - if (/blob|binary|bytea/.test(s)) return 'BLOB'; - return 'TEXT'; -} - -function generateSqlite(db: unknown): string { - const out: string[] = []; - for (const schema of schemasOf(db)) { - for (const table of schema.tables ?? []) { - const lines: string[] = []; - for (const f of table.fields ?? []) { - const type = sqliteType(f.type?.type_name ?? ''); - let line = ` "${f.name}" ${type}`; - if (f.pk) line += type === 'INTEGER' && f.increment ? ' PRIMARY KEY AUTOINCREMENT' : ' PRIMARY KEY'; - if (f.not_null && !f.pk) line += ' NOT NULL'; - if (f.unique && !f.pk) line += ' UNIQUE'; - lines.push(line); - } - // Foreign keys from refs whose FK side is this table. - for (const ref of schema.refs ?? []) { - const [a, b] = ref.endpoints ?? []; - if (!a || !b) continue; - const fk = a.relation === '*' || b.relation === '1' ? a : b; - const pk = fk === a ? b : a; - if (fk.tableName === table.name) { - lines.push(` FOREIGN KEY ("${epField(fk)}") REFERENCES "${pk.tableName}" ("${epField(pk)}")`); - } - } - out.push(`CREATE TABLE "${table.name}" (\n${lines.join(',\n')}\n);`); - } - } - return out.join('\n\n') + '\n'; -} - -/** Map a DBML column type to a ClickHouse type. */ -function clickhouseType(t: string): string { - const s = t.toLowerCase(); - if (/bigint|int8/.test(s)) return 'Int64'; - if (/int/.test(s)) return 'Int32'; - if (/bool/.test(s)) return 'UInt8'; - if (/real|floa|doub|dec|num/.test(s)) return 'Float64'; - if (/datetime|timestamp/.test(s)) return 'DateTime'; - if (/date/.test(s)) return 'Date'; - return 'String'; -} - -function generateClickhouse(db: unknown): string { - const out: string[] = []; - for (const schema of schemasOf(db)) { - for (const table of schema.tables ?? []) { - const fields = table.fields ?? []; - const lines = fields.map((f) => { - const base = clickhouseType(f.type?.type_name ?? ''); - const type = f.not_null || f.pk ? base : `Nullable(${base})`; - return ` "${f.name}" ${type}`; - }); - const pkCols = fields.filter((f) => f.pk).map((f) => `"${f.name}"`); - const orderBy = pkCols.length ? `(${pkCols.join(', ')})` : 'tuple()'; - out.push( - `-- ClickHouse has no FOREIGN KEY constraints; relationships are enforced by the application.\n` + - `CREATE TABLE "${table.name}" (\n${lines.join(',\n')}\n)\nENGINE = MergeTree()\nORDER BY ${orderBy};`, - ); - } - } - return out.join('\n\n') + '\n'; -} -``` - -Note: `Parser` is imported for parity with `dbml.lib` usage but the native path uses `exporter.export` (which parses internally); if your linter flags the unused import, drop `Parser` from the import. - -- [ ] **Step 4: Run to verify it passes (adjust accessors if needed)** - -Run: `npm test -- --run src/tools/draw/sql-export.lib.test.ts` -Expected: PASS. If the sqlite/clickhouse assertions fail on a flag, apply the same one-time model-inspection step from Task 2 Step 4 and adjust the `RawField` accessors. - -- [ ] **Step 5: Commit** - -```bash -git add src/tools/draw/sql-export.lib.ts src/tools/draw/sql-export.lib.test.ts -git commit -m "feat(dbdiagram): SQL export (native pg/mysql/mssql/oracle + custom sqlite/clickhouse)" -``` - ---- - -## Task 5: Image export - -**Files:** -- Create: `src/tools/draw/diagram-image.lib.ts` -- Test: `src/tools/draw/diagram-image.lib.test.ts` - -**Interfaces:** -- Consumes: `html-to-image` (`toPng`, `toJpeg`, `toSvg`, `toCanvas`). -- Produces: - - `type ImageFormat = 'png' | 'jpeg' | 'webp' | 'svg'` - - `mimeFor(format: ImageFormat): string` - - `pixelRatioFor(scale: number): number` - - `exportDiagramImage(el: HTMLElement, opts: { format: ImageFormat; scale?: number; background?: string }): Promise` - -Only the two pure mapping functions are unit-tested; `exportDiagramImage` needs a real DOM/canvas and is verified manually via the island (Task 10). - -- [ ] **Step 1: Write the failing test** - -Create `src/tools/draw/diagram-image.lib.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { mimeFor, pixelRatioFor } from './diagram-image.lib'; - -describe('mimeFor', () => { - it('maps each format to its MIME type', () => { - expect(mimeFor('png')).toBe('image/png'); - expect(mimeFor('jpeg')).toBe('image/jpeg'); - expect(mimeFor('webp')).toBe('image/webp'); - expect(mimeFor('svg')).toBe('image/svg+xml'); - }); -}); - -describe('pixelRatioFor', () => { - it('clamps scale into 1..3', () => { - expect(pixelRatioFor(2)).toBe(2); - expect(pixelRatioFor(0)).toBe(1); - expect(pixelRatioFor(9)).toBe(3); - }); -}); -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `npm test -- --run src/tools/draw/diagram-image.lib.test.ts` -Expected: FAIL — cannot resolve `./diagram-image.lib`. - -- [ ] **Step 3: Write diagram-image.lib** - -Create `src/tools/draw/diagram-image.lib.ts`: - -```ts -import { toPng, toJpeg, toSvg, toCanvas } from 'html-to-image'; - -export type ImageFormat = 'png' | 'jpeg' | 'webp' | 'svg'; - -export function mimeFor(format: ImageFormat): string { - return format === 'svg' ? 'image/svg+xml' : `image/${format}`; -} - -export function pixelRatioFor(scale: number): number { - return Math.min(3, Math.max(1, Math.round(scale) || 1)); -} - -/** - * Render a DOM element (the react-flow viewport, pre-fitted to full bounds by - * the caller) to an image blob. PNG/JPEG/SVG via html-to-image; WebP via canvas. - */ -export async function exportDiagramImage( - el: HTMLElement, - opts: { format: ImageFormat; scale?: number; background?: string }, -): Promise { - const pixelRatio = pixelRatioFor(opts.scale ?? 1); - const bg = opts.background ?? '#ffffff'; - - if (opts.format === 'svg') { - const dataUrl = await toSvg(el, { backgroundColor: bg }); - const res = await fetch(dataUrl); - return res.blob(); - } - if (opts.format === 'png') { - const dataUrl = await toPng(el, { pixelRatio, backgroundColor: bg }); - return (await fetch(dataUrl)).blob(); - } - if (opts.format === 'jpeg') { - const dataUrl = await toJpeg(el, { pixelRatio, quality: 0.95, backgroundColor: bg }); - return (await fetch(dataUrl)).blob(); - } - // webp: render to a canvas, then encode. - const canvas = await toCanvas(el, { pixelRatio, backgroundColor: bg }); - return new Promise((resolve, reject) => - canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Failed to encode WebP'))), 'image/webp', 0.95), - ); -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `npm test -- --run src/tools/draw/diagram-image.lib.test.ts` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/tools/draw/diagram-image.lib.ts src/tools/draw/diagram-image.lib.test.ts -git commit -m "feat(dbdiagram): image export (png/jpeg/webp/svg) with scale mapping" -``` - ---- - -## Task 6: IndexedDB persistence store - -**Files:** -- Create: `src/tools/draw/dbdiagram.store.ts` - -**Interfaces:** -- Consumes: `idb`. -- Produces: - - `type DbDiagramDoc = { dbml: string; positions: Record; updatedAt: number }` - - `loadDoc(): Promise` - - `saveDoc(doc: DbDiagramDoc): Promise` - -Mirrors `whiteboard.store.ts` exactly; no unit test (same untested pattern as the Whiteboard store — verified via the island). - -- [ ] **Step 1: Create the store** - -Create `src/tools/draw/dbdiagram.store.ts`: - -```ts -import { openDB, type IDBPDatabase } from 'idb'; - -// Persist the DB diagram (DBML text + dragged node positions) locally so it -// survives reloads. IndexedDB (not localStorage) for consistency with other tools. -export interface DbDiagramDoc { - dbml: string; - positions: Record; - updatedAt: number; -} - -const DB_NAME = 'gwt-dbdiagram'; -const STORE = 'doc'; -const KEY = 'current'; - -let dbPromise: Promise | null = null; -function db(): Promise { - if (!dbPromise) { - dbPromise = openDB(DB_NAME, 1, { - upgrade(database) { - if (!database.objectStoreNames.contains(STORE)) database.createObjectStore(STORE); - }, - }); - } - return dbPromise; -} - -export async function loadDoc(): Promise { - try { - return (await (await db()).get(STORE, KEY)) ?? null; - } catch { - return null; - } -} - -export async function saveDoc(doc: DbDiagramDoc): Promise { - try { - await (await db()).put(STORE, doc, KEY); - } catch { - /* storage unavailable / quota — best-effort */ - } -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `npm run build` -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```bash -git add src/tools/draw/dbdiagram.store.ts -git commit -m "feat(dbdiagram): IndexedDB persistence for DBML + node positions" -``` - ---- - -## Task 7: Custom diagram node & edge components - -**Files:** -- Create: `src/islands/draw/db-diagram/TableNode.tsx`, `src/islands/draw/db-diagram/RelationEdge.tsx` - -**Interfaces:** -- Consumes: `@xyflow/react` (`Handle`, `Position`, `NodeProps`, `EdgeProps`, `BaseEdge`, `getBezierPath`); `TableColumn` (Task 2). -- Produces: default-exported `TableNode` and `RelationEdge` React components + a `HIGHLIGHT` config object (exported from `TableNode.tsx`) used by both the nodes/edges and the island. - -- [ ] **Step 1: Create the HIGHLIGHT config + TableNode** - -Create `src/islands/draw/db-diagram/TableNode.tsx`: - -```tsx -import { Handle, Position, type NodeProps } from '@xyflow/react'; -import { KeyRound, Link2 } from 'lucide-react'; -import type { TableColumn } from '@/tools/draw/dbml.lib'; - -// Central, tunable emphasis config — bolder than dbdiagram.io's defaults. -export const HIGHLIGHT = { - edgeWidth: 3.5, - edgeWidthIdle: 1.5, - color: 'var(--accent, #f59e0b)', - glow: 'drop-shadow(0 0 4px var(--accent, #f59e0b))', - dimOpacity: 0.25, - nodeBorderWidth: 2.5, -}; - -export interface TableNodeData { - name: string; - columns: TableColumn[]; - /** injected by the island on hover: 'active' | 'neighbor' | 'dim' | undefined */ - emphasis?: 'active' | 'neighbor' | 'dim'; - /** set of `${column}` names to highlight (FK/PK endpoints) */ - hotColumns?: Set; -} - -export default function TableNode({ data }: NodeProps<{ data: TableNodeData } & Record> & { data: TableNodeData }) { - const emphasized = data.emphasis === 'active' || data.emphasis === 'neighbor'; - const style: React.CSSProperties = { - opacity: data.emphasis === 'dim' ? HIGHLIGHT.dimOpacity : 1, - borderWidth: emphasized ? HIGHLIGHT.nodeBorderWidth : 2, - borderColor: emphasized ? HIGHLIGHT.color : undefined, - filter: data.emphasis === 'active' ? HIGHLIGHT.glow : undefined, - transition: 'opacity 120ms, border-color 120ms, filter 120ms', - }; - - return ( -
-
{data.name}
-
- {data.columns.map((c) => { - const hot = data.hotColumns?.has(c.name); - return ( -
- {/* Column-level handles (both sides) so edges attach at the row. */} - - - {c.pk && } - {c.fk && !c.pk && } - {c.name} - - {c.type} - -
- ); - })} -
-
- ); -} -``` - -- [ ] **Step 2: Create RelationEdge** - -Create `src/islands/draw/db-diagram/RelationEdge.tsx`: - -```tsx -import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react'; -import { HIGHLIGHT } from './TableNode'; - -export interface RelationEdgeData { - emphasis?: 'active' | 'dim'; -} - -export default function RelationEdge(props: EdgeProps & { data?: RelationEdgeData }) { - const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props; - const [path] = getBezierPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }); - const active = data?.emphasis === 'active'; - const dim = data?.emphasis === 'dim'; - return ( - - ); -} -``` - -- [ ] **Step 3: Verify compile** - -Run: `npm run build` -Expected: build succeeds (components unused until Task 8; must type-check). If `@xyflow/react`'s `NodeProps`/`EdgeProps` generics complain, relax the node prop type to `NodeProps` and read `data` via `props.data as TableNodeData` — the runtime contract is what matters. - -- [ ] **Step 4: Commit** - -```bash -git add src/islands/draw/db-diagram/TableNode.tsx src/islands/draw/db-diagram/RelationEdge.tsx -git commit -m "feat(dbdiagram): custom TableNode + RelationEdge with HIGHLIGHT config" -``` - ---- - -## Task 8: The island — editor ↔ diagram render + persistence - -**Files:** -- Create: `src/islands/draw/DbDiagram.tsx` -- Modify: `src/registry/tools.ts` - -**Interfaces:** -- Consumes: `parseDbml`, `buildFlow` (Task 2); `layoutNodes` (Task 3); `loadDoc`, `saveDoc`, `DbDiagramDoc` (Task 6); `TableNode`, `RelationEdge` (Task 7); `@xyflow/react` (`ReactFlow`, `Background`, `Controls`, `MiniMap`, `applyNodeChanges`); `@xyflow/react/dist/style.css`. -- Produces: default-exported `DbDiagram` island. - -- [ ] **Step 1: Create the island (core: editor, diagram, persistence, seed)** - -Create `src/islands/draw/DbDiagram.tsx`: - -```tsx -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from 'react'; -import { parseDbml, buildFlow } from '@/tools/draw/dbml.lib'; -import { layoutNodes } from '@/tools/draw/layout.lib'; -import { loadDoc, saveDoc, type DbDiagramDoc } from '@/tools/draw/dbdiagram.store'; -import TableNode from './db-diagram/TableNode'; -import RelationEdge from './db-diagram/RelationEdge'; -import { Alert } from '@/components/ui/Alert'; -import '@xyflow/react/dist/style.css'; - -const SEED = `Table users { - id int [pk, increment] - email varchar [not null, unique] - created_at timestamp -} - -Table posts { - id int [pk, increment] - user_id int [not null] - title varchar - body text -} - -Ref: posts.user_id > users.id -`; - -const nodeTypes = { table: TableNode }; -const edgeTypes = { relation: RelationEdge }; - -export default function DbDiagram() { - // react-flow is browser-only and heavy — load it after mount (like Whiteboard). - const [RF, setRF] = useState<{ - ReactFlow: ComponentType>; - Background: ComponentType>; - Controls: ComponentType>; - MiniMap: ComponentType>; - applyNodeChanges: (changes: unknown[], nodes: unknown[]) => unknown[]; - } | null>(null); - - const [dbml, setDbml] = useState(SEED); - const [nodes, setNodes] = useState[]>([]); - const [edges, setEdges] = useState[]>([]); - const [error, setError] = useState(null); - const positions = useRef>({}); - const loaded = useRef(false); - - useEffect(() => { - let alive = true; - import('@xyflow/react').then((m) => { - if (!alive) return; - setRF({ - ReactFlow: m.ReactFlow as ComponentType>, - Background: m.Background as ComponentType>, - Controls: m.Controls as ComponentType>, - MiniMap: m.MiniMap as ComponentType>, - applyNodeChanges: m.applyNodeChanges as (c: unknown[], n: unknown[]) => unknown[], - }); - }); - return () => { alive = false; }; - }, []); - - // Load persisted doc once. - useEffect(() => { - loadDoc().then((doc) => { - if (doc) { - positions.current = doc.positions ?? {}; - setDbml(doc.dbml || SEED); - } - loaded.current = true; - }); - }, []); - - // Re-parse + re-render whenever DBML changes (debounced). Keep the last good - // diagram on parse errors so the user doesn't lose context mid-typo. - useEffect(() => { - const t = setTimeout(() => { - const { db, error: err } = parseDbml(dbml); - setError(err); - if (err) return; // keep previous nodes/edges - const flow = buildFlow(db); - const positioned = layoutNodes(flow.nodes, flow.edges, positions.current); - setNodes(positioned as unknown as Record[]); - setEdges(flow.edges.map((e) => ({ ...e, type: 'relation' })) as unknown as Record[]); - }, 400); - return () => clearTimeout(t); - }, [dbml]); - - // Debounced autosave of DBML + positions. - useEffect(() => { - if (!loaded.current) return; - const t = setTimeout(() => { - void saveDoc({ dbml, positions: positions.current, updatedAt: Date.now() } satisfies DbDiagramDoc); - }, 800); - return () => clearTimeout(t); - }, [dbml, nodes]); - - const onNodesChange = useCallback( - (changes: unknown[]) => { - if (!RF) return; - setNodes((ns) => { - const next = RF.applyNodeChanges(changes, ns) as { id: string; position: { x: number; y: number } }[]; - // Record dragged positions so re-parses preserve them. - for (const n of next) positions.current[n.id] = n.position; - return next as unknown as Record[]; - }); - }, - [RF], - ); - - const diagram = useMemo(() => { - if (!RF) return
Loading diagram…
; - const { ReactFlow, Background, Controls, MiniMap } = RF; - return ( - - - - - - ); - }, [RF, nodes, edges, onNodesChange]); - - return ( -
-

- Write your schema in DBML on the left; the ER diagram updates live. Drag tables to arrange them — your layout and schema are saved in your browser. -

- - {error && {error}} - -
-