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/.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/.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 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/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). 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/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/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) 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/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. 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) { 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}} - -
-