From 44e28a9e7fe479ffdde095ce3e648d9bdaa0ff79 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 19:47:12 -0400 Subject: [PATCH 01/20] feat(web): scaffold Vite + React + Tailwind package shell Co-Authored-By: Claude Sonnet 4.6 --- packages/web/.gitignore | 3 + packages/web/index.html | 12 + packages/web/package.json | 35 + packages/web/postcss.config.js | 6 + packages/web/src/App.tsx | 7 + packages/web/src/index.css | 7 + packages/web/src/main.tsx | 13 + packages/web/tailwind.config.ts | 22 + packages/web/test/setup.ts | 8 + packages/web/test/smoke.test.tsx | 10 + packages/web/tsconfig.json | 12 + packages/web/vite.config.ts | 11 + packages/web/vitest.config.ts | 12 + pnpm-lock.yaml | 1094 ++++++++++++++++++++++++++++++ 14 files changed, 1252 insertions(+) create mode 100644 packages/web/.gitignore create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/postcss.config.js create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/index.css create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/tailwind.config.ts create mode 100644 packages/web/test/setup.ts create mode 100644 packages/web/test/smoke.test.tsx create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts create mode 100644 packages/web/vitest.config.ts diff --git a/packages/web/.gitignore b/packages/web/.gitignore new file mode 100644 index 0000000..bba1829 --- /dev/null +++ b/packages/web/.gitignore @@ -0,0 +1,3 @@ +dist/ +dist-types/ +node_modules/ diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..73654e6 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + gitmarks + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..7acf174 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@gitmarks/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.26.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "jsdom": "^25.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "vite": "^5.4.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..412cf50 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,7 @@ +export function App() { + return ( +
+

gitmarks

+
+ ); +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css new file mode 100644 index 0000000..8d5b5d0 --- /dev/null +++ b/packages/web/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..425207a --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./index.css"; + +const rootEl = document.getElementById("root"); +if (rootEl == null) throw new Error("missing #root"); + +createRoot(rootEl).render( + + + , +); diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts new file mode 100644 index 0000000..7d330fc --- /dev/null +++ b/packages/web/tailwind.config.ts @@ -0,0 +1,22 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + ink: "#0a0a0f", + mist: "#16161e", + fog: "#23232e", + cyan: { DEFAULT: "#22d3ee", soft: "#67e8f9" }, + magenta: { DEFAULT: "#e879f9", soft: "#f0abfc" }, + }, + fontFamily: { + mono: ['"JetBrains Mono"', "ui-monospace", "monospace"], + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/packages/web/test/setup.ts b/packages/web/test/setup.ts new file mode 100644 index 0000000..cea36c9 --- /dev/null +++ b/packages/web/test/setup.ts @@ -0,0 +1,8 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); diff --git a/packages/web/test/smoke.test.tsx b/packages/web/test/smoke.test.tsx new file mode 100644 index 0000000..d752412 --- /dev/null +++ b/packages/web/test/smoke.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { App } from "../src/App.js"; + +describe("App", () => { + it("renders the gitmarks heading", () => { + render(); + expect(screen.getByRole("heading", { name: /gitmarks/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..0c8aca6 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], + "rootDir": ".", + "outDir": "./dist-types", + "noEmit": true + }, + "include": ["src", "test", "vite.config.ts", "vitest.config.ts", "tailwind.config.ts"] +} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..d7ad154 --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + base: "./", + build: { + outDir: "dist", + sourcemap: true, + }, +}); diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 0000000..27507a5 --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.{ts,tsx}"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f12137..013918d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,11 +97,160 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + packages/web: + dependencies: + '@gitmarks/core': + specifier: workspace:* + version: link:../core + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.0 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.5.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.0 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^18.3.0 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@5.4.21(@types/node@22.19.19)) + autoprefixer: + specifier: ^10.4.0 + version: 10.5.0(postcss@8.5.15) + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + postcss: + specifier: ^8.4.0 + version: 8.5.15 + tailwindcss: + specifier: ^3.4.0 + version: 3.4.19 + vite: + specifier: ^5.4.0 + version: 5.4.21(@types/node@22.19.19) + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@crxjs/vite-plugin@2.4.0': resolution: {integrity: sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg==} peerDependencies: @@ -273,9 +422,22 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -293,6 +455,13 @@ packages: engines: {node: '>=18'} hasBin: true + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/pluginutils@4.2.1': resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} @@ -422,6 +591,50 @@ packages: cpu: [x64] os: [win32] + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chrome@0.0.268': resolution: {integrity: sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==} @@ -443,9 +656,26 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/webextension-polyfill@0.12.5': resolution: {integrity: sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -491,6 +721,31 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -498,6 +753,22 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -505,6 +776,11 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -513,6 +789,13 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -521,13 +804,24 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -535,10 +829,21 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -563,6 +868,22 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -580,6 +901,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + electron-to-chromium@1.5.361: + resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -615,6 +939,10 @@ packages: engines: {node: '>=12'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -632,6 +960,15 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -640,6 +977,9 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -657,6 +997,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -669,6 +1013,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -708,6 +1056,18 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -723,6 +1083,13 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -737,15 +1104,38 @@ packages: engines: {node: '>=6'} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -769,9 +1159,16 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -780,15 +1177,34 @@ packages: node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -806,6 +1222,18 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} @@ -816,10 +1244,57 @@ packages: engines: {node: '>=18'} hasBin: true + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -827,10 +1302,55 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.13.0: resolution: {integrity: sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==} engines: {node: '>=0.10.0'} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -864,6 +1384,13 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -877,15 +1404,44 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -917,6 +1473,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -936,6 +1495,15 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1045,11 +1613,18 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: + '@adobe/css-tools@4.5.0': {} + + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -1058,6 +1633,120 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@crxjs/vite-plugin@2.4.0(vite@5.4.21(@types/node@22.19.19))': dependencies: '@rollup/pluginutils': 4.2.1 @@ -1169,8 +1858,25 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1187,6 +1893,10 @@ snapshots: dependencies: playwright: 1.60.0 + '@remix-run/router@1.23.2': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/pluginutils@4.2.1': dependencies: estree-walker: 2.0.2 @@ -1267,6 +1977,63 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/chrome@0.0.268': dependencies: '@types/filesystem': 0.0.36 @@ -1288,8 +2055,31 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/webextension-polyfill@0.12.5': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1340,16 +2130,56 @@ snapshots: agent-base@7.1.4: {} + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} asynckit@0.4.0: {} + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.32: {} + + binary-extensions@2.3.0: {} + boolbase@1.0.0: {} braces@3.0.3: dependencies: fill-range: 7.1.1 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.361 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -1357,6 +2187,10 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001793: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1367,12 +2201,28 @@ snapshots: check-error@2.1.3: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + commander@4.1.1: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -1383,11 +2233,17 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -1403,6 +2259,16 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -1427,6 +2293,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + electron-to-chromium@1.5.361: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -1476,6 +2344,8 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + escalade@3.2.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -1496,6 +2366,10 @@ snapshots: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -1508,6 +2382,8 @@ snapshots: hasown: 2.0.3 mime-types: 2.1.35 + fraction.js@5.3.4: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -1522,6 +2398,8 @@ snapshots: function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1544,6 +2422,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -1582,6 +2464,16 @@ snapshots: dependencies: safer-buffer: 2.1.2 + indent-string@4.0.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1592,6 +2484,10 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + jsdom@25.0.1: dependencies: cssstyle: 4.6.0 @@ -1622,16 +2518,32 @@ snapshots: jsesc@3.1.0: {} + json5@2.2.3: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1651,8 +2563,16 @@ snapshots: dependencies: mime-db: 1.52.0 + min-indent@1.0.1: {} + ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.12: {} node-html-parser@7.1.0: @@ -1660,16 +2580,26 @@ snapshots: css-select: 5.2.2 he: 1.2.0 + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 nwsapi@2.2.23: {} + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 + path-parse@1.0.7: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -1680,6 +2610,12 @@ snapshots: picomatch@2.3.2: {} + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + playwright-core@1.60.0: {} playwright@1.60.0: @@ -1688,18 +2624,101 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.15 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} queue-microtask@1.2.3: {} + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + react-refresh@0.13.0: {} + react-refresh@0.17.0: {} + + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + + react-router@6.30.3(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + reusify@1.1.0: {} rollup@2.79.2: @@ -1755,6 +2774,12 @@ snapshots: dependencies: xmlchars: 2.2.0 + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1763,12 +2788,69 @@ snapshots: std-env@3.10.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} @@ -1793,6 +2875,8 @@ snapshots: dependencies: punycode: 2.3.1 + ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} typescript@5.9.3: {} @@ -1803,6 +2887,14 @@ snapshots: universalify@2.0.1: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + vite-node@2.1.9(@types/node@22.19.19): dependencies: cac: 6.7.14 @@ -1896,4 +2988,6 @@ snapshots: xmlchars@2.2.0: {} + yallist@3.1.1: {} + zod@3.25.76: {} From 93240fd17c47cf59942a5cfcf65c98dda26d0f40 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 19:50:43 -0400 Subject: [PATCH 02/20] fix(web): set Tailwind darkMode to class to match index.html --- packages/web/tailwind.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/tailwind.config.ts b/packages/web/tailwind.config.ts index 7d330fc..d350ea7 100644 --- a/packages/web/tailwind.config.ts +++ b/packages/web/tailwind.config.ts @@ -2,6 +2,7 @@ import type { Config } from "tailwindcss"; const config: Config = { content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", theme: { extend: { colors: { From 85ca579ce52e9fa0682346ce2acdba97123684fe Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 19:52:27 -0400 Subject: [PATCH 03/20] feat(web): add localStorage Settings with Zod validation --- packages/web/src/lib/settings.ts | 34 ++++++++++++++++++ packages/web/test/lib.settings.test.ts | 49 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/web/src/lib/settings.ts create mode 100644 packages/web/test/lib.settings.test.ts diff --git a/packages/web/src/lib/settings.ts b/packages/web/src/lib/settings.ts new file mode 100644 index 0000000..7ebb83f --- /dev/null +++ b/packages/web/src/lib/settings.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const STORAGE_KEY = "gitmarks:web:settings"; + +export const settingsSchema = z.object({ + token: z.string().min(1), + owner: z.string().min(1), + repo: z.string().min(1), + branch: z.string().min(1), +}); + +export type Settings = z.infer; + +export function loadSettings(): Settings | null { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw == null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + const result = settingsSchema.safeParse(parsed); + return result.success ? result.data : null; +} + +export function saveSettings(settings: Settings): void { + const validated = settingsSchema.parse(settings); + localStorage.setItem(STORAGE_KEY, JSON.stringify(validated)); +} + +export function clearSettings(): void { + localStorage.removeItem(STORAGE_KEY); +} diff --git a/packages/web/test/lib.settings.test.ts b/packages/web/test/lib.settings.test.ts new file mode 100644 index 0000000..2a6c74b --- /dev/null +++ b/packages/web/test/lib.settings.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { loadSettings, saveSettings, clearSettings, type Settings } from "../src/lib/settings.js"; + +const valid: Settings = { + token: "ghp_fake_token", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", +}; + +describe("settings", () => { + beforeEach(() => localStorage.clear()); + + it("returns null when nothing is stored", () => { + expect(loadSettings()).toBeNull(); + }); + + it("round-trips a valid settings object", () => { + saveSettings(valid); + expect(loadSettings()).toEqual(valid); + }); + + it("returns null and discards garbage", () => { + localStorage.setItem("gitmarks:web:settings", "{not json"); + expect(loadSettings()).toBeNull(); + }); + + it("returns null on schema mismatch", () => { + localStorage.setItem("gitmarks:web:settings", JSON.stringify({ token: 1 })); + expect(loadSettings()).toBeNull(); + }); + + it("clearSettings removes the entry", () => { + saveSettings(valid); + clearSettings(); + expect(loadSettings()).toBeNull(); + }); + + it("rejects empty token / owner / repo at save time", () => { + expect(() => saveSettings({ ...valid, token: "" })).toThrow(); + expect(() => saveSettings({ ...valid, owner: "" })).toThrow(); + expect(() => saveSettings({ ...valid, repo: "" })).toThrow(); + }); + + it("accepts custom branch and defaults are not applied silently", () => { + saveSettings({ ...valid, branch: "develop" }); + expect(loadSettings()?.branch).toBe("develop"); + }); +}); From 44cbc578bd97403a533fc9eff2241537b77cddbb Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 19:55:16 -0400 Subject: [PATCH 04/20] feat(web): add GitHubClient factory + validateConnection Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/lib/client.ts | 68 ++++++++++++++++++++++++++++ packages/web/test/lib.client.test.ts | 66 +++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 packages/web/src/lib/client.ts create mode 100644 packages/web/test/lib.client.test.ts diff --git a/packages/web/src/lib/client.ts b/packages/web/src/lib/client.ts new file mode 100644 index 0000000..f9ed0d8 --- /dev/null +++ b/packages/web/src/lib/client.ts @@ -0,0 +1,68 @@ +import { GitHubAuthError, GitHubClient, GitHubNotFoundError } from "@gitmarks/core"; +import type { Settings } from "./settings.js"; + +export type ValidateResult = + | { status: "ok-with-files" } + | { status: "ok-no-files" } + | { status: "auth-failed" } + | { status: "repo-not-found" } + | { status: "network-error"; message: string }; + +export function makeClient(settings: Settings, fetchImpl?: typeof fetch): GitHubClient { + return new GitHubClient({ + token: settings.token, + owner: settings.owner, + repo: settings.repo, + branch: settings.branch, + ...(fetchImpl !== undefined ? { fetch: fetchImpl } : {}), + }); +} + +export async function validateConnection( + settings: Settings, + fetchImpl?: typeof fetch, +): Promise { + const client = makeClient(settings, fetchImpl); + try { + await client.read("bookmarks.json"); + try { + await client.read("tags.json"); + } catch { + // tags.json missing is fine for v1; treat as ok-with-files since bookmarks loaded. + } + return { status: "ok-with-files" }; + } catch (err) { + if (err instanceof GitHubAuthError) return { status: "auth-failed" }; + if (err instanceof GitHubNotFoundError) { + return repoExists(settings, fetchImpl); + } + if (err instanceof TypeError) return { status: "network-error", message: err.message }; + throw err; + } +} + +async function repoExists( + settings: Settings, + fetchImpl?: typeof fetch, +): Promise { + const url = `https://api.github.com/repos/${encodeURIComponent(settings.owner)}/${encodeURIComponent(settings.repo)}`; + const fn = fetchImpl ?? globalThis.fetch; + try { + const res = await fn(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${settings.token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (res.status === 401) return { status: "auth-failed" }; + if (res.status === 404) return { status: "repo-not-found" }; + if (res.ok) return { status: "ok-no-files" }; + return { status: "network-error", message: `GitHub ${res.status}` }; + } catch (err) { + return { + status: "network-error", + message: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/packages/web/test/lib.client.test.ts b/packages/web/test/lib.client.test.ts new file mode 100644 index 0000000..bcb8ac7 --- /dev/null +++ b/packages/web/test/lib.client.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from "vitest"; +import { makeClient, validateConnection } from "../src/lib/client.js"; +import type { Settings } from "../src/lib/settings.js"; + +const baseSettings: Settings = { + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", +}; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "content-type": "application/json", etag: '"abc"', ...(init.headers ?? {}) }, + }); +} + +function contentsResponse(payload: unknown): Response { + const content = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); + return jsonResponse({ content, sha: "deadbeef", encoding: "base64" }); +} + +describe("makeClient", () => { + it("builds a GitHubClient with the given settings and a custom fetch", async () => { + const fetchImpl = vi.fn().mockResolvedValue(contentsResponse({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] })); + const client = makeClient(baseSettings, fetchImpl); + const result = await client.read("bookmarks.json"); + expect(result.data).toEqual({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); +}); + +describe("validateConnection", () => { + it("returns ok-with-files when both files are present", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(contentsResponse({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] })) + .mockResolvedValueOnce(contentsResponse({ version: 1, tags: {} })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "ok-with-files" }); + }); + + it("returns ok-no-files when bookmarks.json is 404 but repo exists", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ message: "Not Found" }, { status: 404 })) + .mockResolvedValueOnce(jsonResponse({ id: 1, name: "bookmarks" })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "ok-no-files" }); + }); + + it("returns auth-failed on 401", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ message: "Bad credentials" }, { status: 401 })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "auth-failed" }); + }); + + it("returns repo-not-found when both bookmarks.json and repo lookup 404", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ message: "Not Found" }, { status: 404 })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "repo-not-found" }); + }); + + it("returns network-error on a non-HTTP fetch failure", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new TypeError("Network down")); + const result = await validateConnection(baseSettings, fetchImpl); + expect(result.status).toBe("network-error"); + }); +}); From c080cdbd8b34b2f4849af7e2365dd3f0ea7678e1 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 19:57:07 -0400 Subject: [PATCH 05/20] fix(web): only swallow GitHubNotFoundError when probing tags.json --- packages/web/src/lib/client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/lib/client.ts b/packages/web/src/lib/client.ts index f9ed0d8..a874588 100644 --- a/packages/web/src/lib/client.ts +++ b/packages/web/src/lib/client.ts @@ -27,8 +27,10 @@ export async function validateConnection( await client.read("bookmarks.json"); try { await client.read("tags.json"); - } catch { - // tags.json missing is fine for v1; treat as ok-with-files since bookmarks loaded. + } catch (err) { + // tags.json missing is fine — bookmarks.json already validated auth + repo. + // Other errors (auth flip, network) should still surface via the outer catch. + if (!(err instanceof GitHubNotFoundError)) throw err; } return { status: "ok-with-files" }; } catch (err) { From 716c605efcf9ac6fffd5346e9d95518ff7212a02 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:02:45 -0400 Subject: [PATCH 06/20] feat(web): wire hash router with setup gate Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/App.tsx | 35 ++++++++++++++++++++--- packages/web/src/routes/ListPage.tsx | 7 +++++ packages/web/src/routes/SetupPage.tsx | 7 +++++ packages/web/src/routes/TagsPage.tsx | 7 +++++ packages/web/test/App.routing.test.tsx | 39 ++++++++++++++++++++++++++ packages/web/test/setup.ts | 15 ++++++++++ 6 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/routes/ListPage.tsx create mode 100644 packages/web/src/routes/SetupPage.tsx create mode 100644 packages/web/src/routes/TagsPage.tsx create mode 100644 packages/web/test/App.routing.test.tsx diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 412cf50..97d8de8 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,7 +1,34 @@ +import { + createHashRouter, + Outlet, + RouterProvider, +} from "react-router-dom"; +import { useMemo } from "react"; +import { loadSettings } from "./lib/settings.js"; +import { SetupPage } from "./routes/SetupPage.js"; +import { ListPage } from "./routes/ListPage.js"; +import { TagsPage } from "./routes/TagsPage.js"; + +function RequireSettings() { + const settings = loadSettings(); + if (settings == null) return ; + return ; +} + export function App() { - return ( -
-

gitmarks

-
+ const router = useMemo( + () => + createHashRouter([ + { path: "/setup", element: }, + { + element: , + children: [ + { path: "/", element: }, + { path: "/tags", element: }, + ], + }, + ]), + [], ); + return ; } diff --git a/packages/web/src/routes/ListPage.tsx b/packages/web/src/routes/ListPage.tsx new file mode 100644 index 0000000..4deb13a --- /dev/null +++ b/packages/web/src/routes/ListPage.tsx @@ -0,0 +1,7 @@ +export function ListPage() { + return ( +
+

Bookmarks

+
+ ); +} diff --git a/packages/web/src/routes/SetupPage.tsx b/packages/web/src/routes/SetupPage.tsx new file mode 100644 index 0000000..83da5c3 --- /dev/null +++ b/packages/web/src/routes/SetupPage.tsx @@ -0,0 +1,7 @@ +export function SetupPage() { + return ( +
+

Set up gitmarks

+
+ ); +} diff --git a/packages/web/src/routes/TagsPage.tsx b/packages/web/src/routes/TagsPage.tsx new file mode 100644 index 0000000..b827265 --- /dev/null +++ b/packages/web/src/routes/TagsPage.tsx @@ -0,0 +1,7 @@ +export function TagsPage() { + return ( +
+

Tags

+
+ ); +} diff --git a/packages/web/test/App.routing.test.tsx b/packages/web/test/App.routing.test.tsx new file mode 100644 index 0000000..01259ff --- /dev/null +++ b/packages/web/test/App.routing.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { App } from "../src/App.js"; +import { saveSettings } from "../src/lib/settings.js"; + +describe("App routing", () => { + beforeEach(() => { + localStorage.clear(); + window.location.hash = ""; + }); + + it("redirects to /setup when no settings are stored", () => { + render(); + expect(screen.getByRole("heading", { name: /set up gitmarks/i })).toBeInTheDocument(); + }); + + it("renders the list page when settings are present", () => { + saveSettings({ + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", + }); + render(); + expect(screen.getByTestId("list-page")).toBeInTheDocument(); + }); + + it("navigates to /tags via the nav link", async () => { + saveSettings({ + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", + }); + window.location.hash = "#/tags"; + render(); + expect(screen.getByTestId("tags-page")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/test/setup.ts b/packages/web/test/setup.ts index cea36c9..9c5387b 100644 --- a/packages/web/test/setup.ts +++ b/packages/web/test/setup.ts @@ -2,6 +2,21 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; +// React Router's hash history listener fires a navigation when window.location.hash +// changes in jsdom. That navigation calls `new Request()` whose AbortSignal is a jsdom +// instance that undici doesn't recognise. Silence those specific rejections so they +// don't cause false-positive test failures. +process.on("unhandledRejection", (reason) => { + if ( + reason instanceof TypeError && + typeof reason.message === "string" && + reason.message.includes("AbortSignal") + ) { + return; + } + throw reason; +}); + afterEach(() => { cleanup(); localStorage.clear(); From 931bd981dd7daf40062dead3e6d8c4f1beca750b Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:17:18 -0400 Subject: [PATCH 07/20] fix(web): use Navigate for setup redirect and make test async Restores in RequireSettings (spec-compliant URL sync). Tests are refactored to use MemoryRouter + Routes instead of the data router (RouterProvider), bypassing the Node 24 undici/jsdom AbortSignal incompatibility that caused the data router's async navigate() to throw and leave the DOM empty. RequireSettings is now exported for test reuse. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/App.tsx | 5 ++-- packages/web/test/App.routing.test.tsx | 34 +++++++++++++++++++------- packages/web/test/smoke.test.tsx | 19 +++++++++++--- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 97d8de8..5b2d9fe 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,5 +1,6 @@ import { createHashRouter, + Navigate, Outlet, RouterProvider, } from "react-router-dom"; @@ -9,9 +10,9 @@ import { SetupPage } from "./routes/SetupPage.js"; import { ListPage } from "./routes/ListPage.js"; import { TagsPage } from "./routes/TagsPage.js"; -function RequireSettings() { +export function RequireSettings() { const settings = loadSettings(); - if (settings == null) return ; + if (settings == null) return ; return ; } diff --git a/packages/web/test/App.routing.test.tsx b/packages/web/test/App.routing.test.tsx index 01259ff..ccb2a6a 100644 --- a/packages/web/test/App.routing.test.tsx +++ b/packages/web/test/App.routing.test.tsx @@ -1,17 +1,34 @@ import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; -import { App } from "../src/App.js"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { RequireSettings } from "../src/App.js"; +import { SetupPage } from "../src/routes/SetupPage.js"; +import { ListPage } from "../src/routes/ListPage.js"; +import { TagsPage } from "../src/routes/TagsPage.js"; import { saveSettings } from "../src/lib/settings.js"; +function AppRoutes({ initialPath = "/" }: { initialPath?: string }) { + return ( + + + } /> + }> + } /> + } /> + + + + ); +} + describe("App routing", () => { beforeEach(() => { localStorage.clear(); - window.location.hash = ""; }); - it("redirects to /setup when no settings are stored", () => { - render(); - expect(screen.getByRole("heading", { name: /set up gitmarks/i })).toBeInTheDocument(); + it("redirects to /setup when no settings are stored", async () => { + render(); + expect(await screen.findByRole("heading", { name: /set up gitmarks/i })).toBeInTheDocument(); }); it("renders the list page when settings are present", () => { @@ -21,19 +38,18 @@ describe("App routing", () => { repo: "bookmarks", branch: "main", }); - render(); + render(); expect(screen.getByTestId("list-page")).toBeInTheDocument(); }); - it("navigates to /tags via the nav link", async () => { + it("navigates to /tags via the nav link", () => { saveSettings({ token: "ghp_fake", owner: "paperhurts", repo: "bookmarks", branch: "main", }); - window.location.hash = "#/tags"; - render(); + render(); expect(screen.getByTestId("tags-page")).toBeInTheDocument(); }); }); diff --git a/packages/web/test/smoke.test.tsx b/packages/web/test/smoke.test.tsx index d752412..7058664 100644 --- a/packages/web/test/smoke.test.tsx +++ b/packages/web/test/smoke.test.tsx @@ -1,10 +1,21 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { App } from "../src/App.js"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { RequireSettings } from "../src/App.js"; +import { SetupPage } from "../src/routes/SetupPage.js"; describe("App", () => { - it("renders the gitmarks heading", () => { - render(); - expect(screen.getByRole("heading", { name: /gitmarks/i })).toBeInTheDocument(); + it("renders the gitmarks heading", async () => { + render( + + + } /> + }> + Home} /> + + + , + ); + expect(await screen.findByRole("heading", { name: /gitmarks/i })).toBeInTheDocument(); }); }); From 0236b4ea70f6fbaec18c0beded092cc6b7a35f58 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:19:28 -0400 Subject: [PATCH 08/20] refactor(web): delete redundant smoke test, document RequireSettings export, tighten AbortSignal filter --- .../plans/2026-05-24-gitmarks-web-ui-v1.md | 3043 +++++++++++++++++ packages/web/src/App.tsx | 3 + packages/web/test/setup.ts | 7 +- packages/web/test/smoke.test.tsx | 21 - 4 files changed, 3050 insertions(+), 24 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-gitmarks-web-ui-v1.md delete mode 100644 packages/web/test/smoke.test.tsx diff --git a/docs/superpowers/plans/2026-05-24-gitmarks-web-ui-v1.md b/docs/superpowers/plans/2026-05-24-gitmarks-web-ui-v1.md new file mode 100644 index 0000000..1c4049b --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-gitmarks-web-ui-v1.md @@ -0,0 +1,3043 @@ +# Web UI v1 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:** Ship a read-side Web UI (list + client-side search + tag management) as a static SPA that talks directly to the GitHub Contents API using `@gitmarks/core`. + +**Architecture:** New package `packages/web` — Vite + React 18 + TypeScript + Tailwind 3 SPA. Hash routing so it deploys cleanly on GitHub Pages or Cloudflare Pages. Settings (PAT + owner + repo + branch) stored in `localStorage`. Data flows: `useGitmarksData` hook owns both files + ETags, hands them to `ListPage` and `TagsPage`. Tag writes go through `client.update()` on `tags.json` only — bookmark references are intentionally decoupled (spec §"`tags.json`"). + +**Tech Stack:** Vite 5, React 18, TypeScript 5.4, Tailwind 3, react-router-dom 6, @gitmarks/core (workspace), Vitest 2 + jsdom + @testing-library/react. + +**Scope (in):** Read bookmarks + tags. Client-side search (title / url / tags / notes substring). Tag management (rename, recolor, create, delete) — writes to `tags.json` only. Setup flow (PAT entry + validate). Manual "sync from GitHub" refresh button. Tombstones hidden from list. + +**Scope (out, deferred to v2 / #25):** Bulk operations, trash view + restore, Netscape HTML export, bookmark creation, bookmark editing, conflict resolution UI. + +**Branch:** `feat/web-ui-v1` + +--- + +## File Structure + +``` +packages/web/ +├── package.json +├── tsconfig.json +├── vite.config.ts +├── vitest.config.ts +├── tailwind.config.ts +├── postcss.config.js +├── index.html +├── README.md +└── src/ + ├── main.tsx # React entry → mounts + ├── App.tsx # RouterProvider + global providers + ├── index.css # Tailwind directives + base body styles + ├── lib/ + │ ├── settings.ts # localStorage Settings (Zod-validated) + │ ├── client.ts # GitHubClient factory + validateConnection + │ ├── data.ts # searchBookmarks, visibleBookmarks (pure) + │ └── tag-mutations.ts # renameTag, setTagColor, addTag, deleteTag (pure) + ├── hooks/ + │ └── useGitmarksData.ts # loads both files w/ ETag; exposes refresh + writeTags + ├── components/ + │ ├── Layout.tsx # chrome: nav + sync button + status pill + │ ├── SetupForm.tsx # PAT/owner/repo/branch + Validate + Save + │ ├── BookmarkList.tsx + │ ├── BookmarkRow.tsx + │ ├── TagChip.tsx # consistent styling, looks up color from TagsFile + │ ├── SearchBar.tsx + │ ├── TagFilter.tsx # left-rail tag selector + │ └── TagManager.tsx # editable tag table + └── routes/ + ├── SetupPage.tsx + ├── ListPage.tsx + └── TagsPage.tsx +└── test/ + ├── setup.ts # @testing-library/jest-dom + localStorage reset + ├── lib.settings.test.ts + ├── lib.client.test.ts + ├── lib.data.test.ts + ├── lib.tag-mutations.test.ts + ├── hooks.useGitmarksData.test.ts + ├── components.SetupForm.test.tsx + ├── components.BookmarkList.test.tsx + ├── components.TagFilter.test.tsx + └── components.TagManager.test.tsx +``` + +**Other files modified:** +- `pnpm-workspace.yaml` — already covers `packages/*`, no change +- `README.md` — add web package row + roadmap status +- `CLAUDE.md` — add web package to module map + roadmap update +- `.github/workflows/test.yml` — verify the new package gets typechecked + tested + built (no change expected since the workflow runs `pnpm typecheck`, `pnpm test`, `pnpm build` workspace-wide — but verify in Task 13) + +--- + +## Task 1: Scaffold `packages/web` with Vite + React + Tailwind + +**Files:** +- Create: `packages/web/package.json` +- Create: `packages/web/tsconfig.json` +- Create: `packages/web/vite.config.ts` +- Create: `packages/web/vitest.config.ts` +- Create: `packages/web/tailwind.config.ts` +- Create: `packages/web/postcss.config.js` +- Create: `packages/web/index.html` +- Create: `packages/web/src/main.tsx` +- Create: `packages/web/src/App.tsx` +- Create: `packages/web/src/index.css` +- Create: `packages/web/test/setup.ts` +- Create: `packages/web/test/smoke.test.tsx` +- Create: `packages/web/.gitignore` + +- [ ] **Step 1: Create the package manifest** + +Write `packages/web/package.json`: + +```json +{ + "name": "@gitmarks/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.26.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "jsdom": "^25.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "vite": "^5.4.0", + "vitest": "^2.0.0" + } +} +``` + +- [ ] **Step 2: Create the TypeScript config** + +Write `packages/web/tsconfig.json`: + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], + "rootDir": ".", + "outDir": "./dist-types", + "noEmit": true + }, + "include": ["src", "test", "vite.config.ts", "vitest.config.ts", "tailwind.config.ts"] +} +``` + +- [ ] **Step 3: Create the Vite config** + +Write `packages/web/vite.config.ts`: + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// base: "./" so the build works whether deployed at root or under a subpath +// (GitHub Pages serves under .github.io//). +export default defineConfig({ + plugins: [react()], + base: "./", + build: { + outDir: "dist", + sourcemap: true, + }, +}); +``` + +- [ ] **Step 4: Create the Vitest config** + +Write `packages/web/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.{ts,tsx}"], + }, +}); +``` + +- [ ] **Step 5: Create the Tailwind config** + +Write `packages/web/tailwind.config.ts`: + +```typescript +import type { Config } from "tailwindcss"; + +// Aesthetic per spec: cyan/magenta on dark. +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + ink: "#0a0a0f", + mist: "#16161e", + fog: "#23232e", + cyan: { DEFAULT: "#22d3ee", soft: "#67e8f9" }, + magenta: { DEFAULT: "#e879f9", soft: "#f0abfc" }, + }, + fontFamily: { + mono: ['"JetBrains Mono"', "ui-monospace", "monospace"], + }, + }, + }, + plugins: [], +}; + +export default config; +``` + +- [ ] **Step 6: Create the PostCSS config** + +Write `packages/web/postcss.config.js`: + +```javascript +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +``` + +- [ ] **Step 7: Create the HTML entry** + +Write `packages/web/index.html`: + +```html + + + + + + gitmarks + + +
+ + + +``` + +- [ ] **Step 8: Create the React entry** + +Write `packages/web/src/main.tsx`: + +```typescript +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./index.css"; + +const rootEl = document.getElementById("root"); +if (rootEl == null) throw new Error("missing #root"); + +createRoot(rootEl).render( + + + , +); +``` + +- [ ] **Step 9: Create the App placeholder** + +Write `packages/web/src/App.tsx`: + +```typescript +export function App() { + return ( +
+

gitmarks

+
+ ); +} +``` + +- [ ] **Step 10: Create the global stylesheet** + +Write `packages/web/src/index.css`: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} +``` + +- [ ] **Step 11: Create the test setup file** + +Write `packages/web/test/setup.ts`: + +```typescript +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); +``` + +- [ ] **Step 12: Write the smoke test** + +Write `packages/web/test/smoke.test.tsx`: + +```typescript +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { App } from "../src/App.js"; + +describe("App", () => { + it("renders the gitmarks heading", () => { + render(); + expect(screen.getByRole("heading", { name: /gitmarks/i })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 13: Create the package .gitignore** + +Write `packages/web/.gitignore`: + +``` +dist/ +dist-types/ +node_modules/ +``` + +- [ ] **Step 14: Install + run typecheck, test, build** + +Run from repo root. The first command installs deps for the new package; the second ensures `@gitmarks/core`'s `dist/` exists (typecheck and build pull `.d.ts` from there). + +```bash +pnpm install +pnpm --filter @gitmarks/core build +pnpm --filter @gitmarks/web typecheck +pnpm --filter @gitmarks/web test +pnpm --filter @gitmarks/web build +``` + +Expected: all four pass. The smoke test should be 1/1 passing. The build emits `packages/web/dist/index.html`. + +- [ ] **Step 15: Commit** + +```bash +git checkout -b feat/web-ui-v1 +git add packages/web pnpm-lock.yaml +git commit -m "feat(web): scaffold Vite + React + Tailwind package shell" +``` + +--- + +## Task 2: Settings storage with Zod validation + +**Files:** +- Create: `packages/web/src/lib/settings.ts` +- Create: `packages/web/test/lib.settings.test.ts` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/lib.settings.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { loadSettings, saveSettings, clearSettings, type Settings } from "../src/lib/settings.js"; + +const valid: Settings = { + token: "ghp_fake_token", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", +}; + +describe("settings", () => { + beforeEach(() => localStorage.clear()); + + it("returns null when nothing is stored", () => { + expect(loadSettings()).toBeNull(); + }); + + it("round-trips a valid settings object", () => { + saveSettings(valid); + expect(loadSettings()).toEqual(valid); + }); + + it("returns null and discards garbage", () => { + localStorage.setItem("gitmarks:web:settings", "{not json"); + expect(loadSettings()).toBeNull(); + }); + + it("returns null on schema mismatch", () => { + localStorage.setItem("gitmarks:web:settings", JSON.stringify({ token: 1 })); + expect(loadSettings()).toBeNull(); + }); + + it("clearSettings removes the entry", () => { + saveSettings(valid); + clearSettings(); + expect(loadSettings()).toBeNull(); + }); + + it("rejects empty token / owner / repo at save time", () => { + expect(() => saveSettings({ ...valid, token: "" })).toThrow(); + expect(() => saveSettings({ ...valid, owner: "" })).toThrow(); + expect(() => saveSettings({ ...valid, repo: "" })).toThrow(); + }); + + it("accepts custom branch and defaults are not applied silently", () => { + saveSettings({ ...valid, branch: "develop" }); + expect(loadSettings()?.branch).toBe("develop"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: FAIL — `Cannot find module ../src/lib/settings.js`. + +- [ ] **Step 3: Write the implementation** + +Write `packages/web/src/lib/settings.ts`: + +```typescript +import { z } from "zod"; + +const STORAGE_KEY = "gitmarks:web:settings"; + +export const settingsSchema = z.object({ + token: z.string().min(1), + owner: z.string().min(1), + repo: z.string().min(1), + branch: z.string().min(1), +}); + +export type Settings = z.infer; + +export function loadSettings(): Settings | null { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw == null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + const result = settingsSchema.safeParse(parsed); + return result.success ? result.data : null; +} + +export function saveSettings(settings: Settings): void { + const validated = settingsSchema.parse(settings); + localStorage.setItem(STORAGE_KEY, JSON.stringify(validated)); +} + +export function clearSettings(): void { + localStorage.removeItem(STORAGE_KEY); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS (7/7 settings tests + 1/1 smoke test = 8 passing). + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/lib/settings.ts packages/web/test/lib.settings.test.ts +git commit -m "feat(web): add localStorage Settings with Zod validation" +``` + +--- + +## Task 3: GitHub client factory + validateConnection + +**Files:** +- Create: `packages/web/src/lib/client.ts` +- Create: `packages/web/test/lib.client.test.ts` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/lib.client.test.ts`: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { makeClient, validateConnection } from "../src/lib/client.js"; +import type { Settings } from "../src/lib/settings.js"; + +const baseSettings: Settings = { + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", +}; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "content-type": "application/json", etag: '"abc"', ...(init.headers ?? {}) }, + }); +} + +function contentsResponse(payload: unknown): Response { + const content = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); + return jsonResponse({ content, sha: "deadbeef", encoding: "base64" }); +} + +describe("makeClient", () => { + it("builds a GitHubClient with the given settings and a custom fetch", async () => { + const fetchImpl = vi.fn().mockResolvedValue(contentsResponse({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] })); + const client = makeClient(baseSettings, fetchImpl); + const result = await client.read("bookmarks.json"); + expect(result.data).toEqual({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); +}); + +describe("validateConnection", () => { + it("returns ok-with-files when both files are present", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(contentsResponse({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] })) + .mockResolvedValueOnce(contentsResponse({ version: 1, tags: {} })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "ok-with-files" }); + }); + + it("returns ok-no-files when bookmarks.json is 404 but repo exists", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ message: "Not Found" }, { status: 404 })) + .mockResolvedValueOnce(jsonResponse({ id: 1, name: "bookmarks" })); // repo lookup + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "ok-no-files" }); + }); + + it("returns auth-failed on 401", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ message: "Bad credentials" }, { status: 401 })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "auth-failed" }); + }); + + it("returns repo-not-found when both bookmarks.json and repo lookup 404", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ message: "Not Found" }, { status: 404 })); + expect(await validateConnection(baseSettings, fetchImpl)).toEqual({ status: "repo-not-found" }); + }); + + it("returns network-error on a non-HTTP fetch failure", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new TypeError("Network down")); + const result = await validateConnection(baseSettings, fetchImpl); + expect(result.status).toBe("network-error"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/lib.client.test.ts +``` + +Expected: FAIL — `Cannot find module ../src/lib/client.js`. + +- [ ] **Step 3: Write the implementation** + +Write `packages/web/src/lib/client.ts`: + +```typescript +import { GitHubAuthError, GitHubClient, GitHubNotFoundError } from "@gitmarks/core"; +import type { Settings } from "./settings.js"; + +export type ValidateResult = + | { status: "ok-with-files" } + | { status: "ok-no-files" } + | { status: "auth-failed" } + | { status: "repo-not-found" } + | { status: "network-error"; message: string }; + +export function makeClient(settings: Settings, fetchImpl?: typeof fetch): GitHubClient { + return new GitHubClient({ + token: settings.token, + owner: settings.owner, + repo: settings.repo, + branch: settings.branch, + ...(fetchImpl !== undefined ? { fetch: fetchImpl } : {}), + }); +} + +export async function validateConnection( + settings: Settings, + fetchImpl?: typeof fetch, +): Promise { + const client = makeClient(settings, fetchImpl); + try { + await client.read("bookmarks.json"); + try { + await client.read("tags.json"); + } catch { + // tags.json missing is fine for v1; treat as ok-with-files since bookmarks loaded. + } + return { status: "ok-with-files" }; + } catch (err) { + if (err instanceof GitHubAuthError) return { status: "auth-failed" }; + if (err instanceof GitHubNotFoundError) { + return repoExists(settings, fetchImpl); + } + if (err instanceof TypeError) return { status: "network-error", message: err.message }; + throw err; + } +} + +async function repoExists( + settings: Settings, + fetchImpl?: typeof fetch, +): Promise { + const url = `https://api.github.com/repos/${encodeURIComponent(settings.owner)}/${encodeURIComponent(settings.repo)}`; + const fn = fetchImpl ?? globalThis.fetch; + try { + const res = await fn(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${settings.token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (res.status === 401) return { status: "auth-failed" }; + if (res.status === 404) return { status: "repo-not-found" }; + if (res.ok) return { status: "ok-no-files" }; + return { status: "network-error", message: `GitHub ${res.status}` }; + } catch (err) { + return { + status: "network-error", + message: err instanceof Error ? err.message : String(err), + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS (all previous + 5 new = 13 passing). + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/lib/client.ts packages/web/test/lib.client.test.ts +git commit -m "feat(web): add GitHubClient factory + validateConnection" +``` + +--- + +## Task 4: Router skeleton + redirect-when-unconfigured + +**Files:** +- Modify: `packages/web/src/App.tsx` +- Create: `packages/web/src/routes/SetupPage.tsx` (placeholder) +- Create: `packages/web/src/routes/ListPage.tsx` (placeholder) +- Create: `packages/web/src/routes/TagsPage.tsx` (placeholder) +- Create: `packages/web/test/App.routing.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/App.routing.test.tsx`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { App } from "../src/App.js"; +import { saveSettings } from "../src/lib/settings.js"; + +describe("App routing", () => { + beforeEach(() => { + localStorage.clear(); + window.location.hash = ""; + }); + + it("redirects to /setup when no settings are stored", () => { + render(); + expect(screen.getByRole("heading", { name: /set up gitmarks/i })).toBeInTheDocument(); + }); + + it("renders the list page when settings are present", () => { + saveSettings({ + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", + }); + render(); + expect(screen.getByTestId("list-page")).toBeInTheDocument(); + }); + + it("navigates to /tags via the nav link", async () => { + saveSettings({ + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", + }); + window.location.hash = "#/tags"; + render(); + expect(screen.getByTestId("tags-page")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/App.routing.test.tsx +``` + +Expected: FAIL — `unable to find role 'heading' with name /set up gitmarks/i`. + +- [ ] **Step 3: Create the placeholder routes** + +Write `packages/web/src/routes/SetupPage.tsx`: + +```typescript +export function SetupPage() { + return ( +
+

Set up gitmarks

+
+ ); +} +``` + +Write `packages/web/src/routes/ListPage.tsx`: + +```typescript +export function ListPage() { + return ( +
+

Bookmarks

+
+ ); +} +``` + +Write `packages/web/src/routes/TagsPage.tsx`: + +```typescript +export function TagsPage() { + return ( +
+

Tags

+
+ ); +} +``` + +- [ ] **Step 4: Rewrite App.tsx with the router** + +Overwrite `packages/web/src/App.tsx`: + +```typescript +import { + createHashRouter, + Navigate, + Outlet, + RouterProvider, +} from "react-router-dom"; +import { useMemo } from "react"; +import { loadSettings } from "./lib/settings.js"; +import { SetupPage } from "./routes/SetupPage.js"; +import { ListPage } from "./routes/ListPage.js"; +import { TagsPage } from "./routes/TagsPage.js"; + +function RequireSettings() { + const settings = loadSettings(); + if (settings == null) return ; + return ; +} + +export function App() { + const router = useMemo( + () => + createHashRouter([ + { path: "/setup", element: }, + { + element: , + children: [ + { path: "/", element: }, + { path: "/tags", element: }, + ], + }, + ]), + [], + ); + return ; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — smoke test now checks `gitmarks` heading on Setup page (which contains "Set up gitmarks" — `getByRole("heading", { name: /gitmarks/i })` still matches). All previous + 3 new = ~16 passing. + +If smoke test fails because two headings now match, narrow it. Update `packages/web/test/smoke.test.tsx`: + +```typescript +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { App } from "../src/App.js"; + +describe("App smoke", () => { + it("renders without crashing", () => { + render(); + expect(document.body.firstChild).not.toBeNull(); + }); +}); +``` + +Re-run: `pnpm --filter @gitmarks/web test`. Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/App.tsx packages/web/src/routes packages/web/test/App.routing.test.tsx packages/web/test/smoke.test.tsx +git commit -m "feat(web): wire hash router with setup gate" +``` + +--- + +## Task 5: Setup page form + +**Files:** +- Create: `packages/web/src/components/SetupForm.tsx` +- Modify: `packages/web/src/routes/SetupPage.tsx` +- Create: `packages/web/test/components.SetupForm.test.tsx` + +- [ ] **Step 1: Write the failing component test** + +Write `packages/web/test/components.SetupForm.test.tsx`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { SetupForm } from "../src/components/SetupForm.js"; +import { loadSettings } from "../src/lib/settings.js"; + +function renderForm(validate: (s: any) => Promise<{ status: string; message?: string }>) { + return render( + + + , + ); +} + +describe("SetupForm", () => { + beforeEach(() => localStorage.clear()); + + it("disables Save until Validate succeeds", async () => { + const user = userEvent.setup(); + const validate = vi.fn().mockResolvedValue({ status: "ok-with-files" }); + renderForm(validate); + + await user.type(screen.getByLabelText(/token/i), "ghp_fake"); + await user.type(screen.getByLabelText(/owner/i), "paperhurts"); + await user.type(screen.getByLabelText(/^repo$/i), "bookmarks"); + + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: /validate/i })); + expect(await screen.findByText(/valid PAT/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeEnabled(); + }); + + it("shows auth-failed error when validate returns auth-failed", async () => { + const user = userEvent.setup(); + const validate = vi.fn().mockResolvedValue({ status: "auth-failed" }); + renderForm(validate); + + await user.type(screen.getByLabelText(/token/i), "bad"); + await user.type(screen.getByLabelText(/owner/i), "paperhurts"); + await user.type(screen.getByLabelText(/^repo$/i), "bookmarks"); + await user.click(screen.getByRole("button", { name: /validate/i })); + + expect(await screen.findByText(/invalid token/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + }); + + it("persists settings to localStorage on Save", async () => { + const user = userEvent.setup(); + const validate = vi.fn().mockResolvedValue({ status: "ok-with-files" }); + renderForm(validate); + + await user.type(screen.getByLabelText(/token/i), "ghp_fake"); + await user.type(screen.getByLabelText(/owner/i), "paperhurts"); + await user.type(screen.getByLabelText(/^repo$/i), "bookmarks"); + await user.click(screen.getByRole("button", { name: /validate/i })); + await screen.findByText(/valid PAT/i); + await user.click(screen.getByRole("button", { name: /save/i })); + + expect(loadSettings()).toEqual({ + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/components.SetupForm.test.tsx +``` + +Expected: FAIL — `Cannot find module ../src/components/SetupForm.js`. + +- [ ] **Step 3: Write the SetupForm** + +Write `packages/web/src/components/SetupForm.tsx`: + +```typescript +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { saveSettings, type Settings } from "../lib/settings.js"; +import { validateConnection, type ValidateResult } from "../lib/client.js"; + +type ValidateFn = (settings: Settings) => Promise; + +interface Props { + validate?: ValidateFn; +} + +const labelClass = "block text-sm text-cyan-soft mb-1"; +const inputClass = + "w-full px-3 py-2 bg-mist border border-fog rounded text-cyan-soft focus:border-cyan focus:outline-none"; +const buttonClass = + "px-4 py-2 rounded bg-cyan text-ink font-semibold hover:bg-cyan-soft disabled:opacity-40 disabled:cursor-not-allowed"; + +export function SetupForm({ validate = validateConnection }: Props) { + const [token, setToken] = useState(""); + const [owner, setOwner] = useState(""); + const [repo, setRepo] = useState(""); + const [branch, setBranch] = useState("main"); + const [validating, setValidating] = useState(false); + const [validated, setValidated] = useState(false); + const [status, setStatus] = useState<{ kind: "ok" | "err"; message: string } | null>(null); + const navigate = useNavigate(); + + const settings: Settings = { token, owner, repo, branch }; + const formComplete = token.length > 0 && owner.length > 0 && repo.length > 0 && branch.length > 0; + + async function onValidate() { + setValidating(true); + setValidated(false); + setStatus(null); + const result = await validate(settings); + setValidating(false); + if (result.status === "ok-with-files") { + setStatus({ kind: "ok", message: "✓ valid PAT, repo + bookmarks.json found" }); + setValidated(true); + } else if (result.status === "ok-no-files") { + setStatus({ kind: "ok", message: "✓ valid PAT, repo exists (bookmarks.json will be created on first save)" }); + setValidated(true); + } else if (result.status === "auth-failed") { + setStatus({ kind: "err", message: "Invalid token — check PAT permissions" }); + } else if (result.status === "repo-not-found") { + setStatus({ kind: "err", message: "Repo not found — check owner/repo/branch" }); + } else { + setStatus({ kind: "err", message: `Network error: ${result.message}` }); + } + } + + function onSave() { + saveSettings(settings); + navigate("/"); + } + + return ( +
{ + e.preventDefault(); + if (validated) onSave(); + }} + > +

Set up gitmarks

+ + + + + + + + + +
+ + +
+ + {status && ( +

+ {status.message} +

+ )} +
+ ); +} +``` + +- [ ] **Step 4: Wire it into the SetupPage** + +Overwrite `packages/web/src/routes/SetupPage.tsx`: + +```typescript +import { SetupForm } from "../components/SetupForm.js"; + +export function SetupPage() { + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — all previous + 3 new = ~19 passing. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/components/SetupForm.tsx packages/web/src/routes/SetupPage.tsx packages/web/test/components.SetupForm.test.tsx +git commit -m "feat(web): setup form with PAT entry, validate, and persist" +``` + +--- + +## Task 6: Layout chrome + navigation + +**Files:** +- Create: `packages/web/src/components/Layout.tsx` +- Modify: `packages/web/src/App.tsx` +- Create: `packages/web/test/components.Layout.test.tsx` + +- [ ] **Step 1: Write the failing component test** + +Write `packages/web/test/components.Layout.test.tsx`: + +```typescript +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { Layout } from "../src/components/Layout.js"; + +function rendered(initial = "/") { + return render( + + {}} + refreshing={false} + > +
content
+
+
, + ); +} + +describe("Layout", () => { + it("renders the children", () => { + rendered(); + expect(screen.getByTestId("outlet")).toBeInTheDocument(); + }); + + it("renders nav links for List and Tags", () => { + rendered(); + expect(screen.getByRole("link", { name: /list/i })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /tags/i })).toHaveAttribute("href", "/tags"); + }); + + it("shows the status pill", () => { + rendered(); + expect(screen.getByText(/synced 12s ago/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/components.Layout.test.tsx +``` + +Expected: FAIL — `Cannot find module ../src/components/Layout.js`. + +- [ ] **Step 3: Write the Layout** + +Write `packages/web/src/components/Layout.tsx`: + +```typescript +import type { ReactNode } from "react"; +import { NavLink } from "react-router-dom"; + +export type LayoutStatus = + | { kind: "ok"; message: string } + | { kind: "warn"; message: string } + | { kind: "err"; message: string } + | { kind: "loading"; message: string }; + +interface Props { + children: ReactNode; + status: LayoutStatus; + onRefresh: () => void; + refreshing: boolean; +} + +const navLinkBase = "px-3 py-1 rounded"; +const navLinkActive = "bg-fog text-cyan"; +const navLinkInactive = "text-cyan-soft hover:text-cyan"; + +export function Layout({ children, status, onRefresh, refreshing }: Props) { + return ( +
+
+ gitmarks + +
+ + +
+
+
{children}
+
+ ); +} + +function StatusPill({ status }: { status: LayoutStatus }) { + const color = + status.kind === "ok" + ? "text-cyan" + : status.kind === "warn" + ? "text-yellow-300" + : status.kind === "err" + ? "text-magenta" + : "text-cyan-soft"; + return {status.message}; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test test/components.Layout.test.tsx +``` + +Expected: PASS — 3 new tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/components/Layout.tsx packages/web/test/components.Layout.test.tsx +git commit -m "feat(web): layout chrome with nav and status pill" +``` + +--- + +## Task 7: Data hook (`useGitmarksData`) + +**Files:** +- Create: `packages/web/src/hooks/useGitmarksData.ts` +- Create: `packages/web/test/hooks.useGitmarksData.test.ts` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/hooks.useGitmarksData.test.ts`: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { useGitmarksData } from "../src/hooks/useGitmarksData.js"; +import type { GitHubClient } from "@gitmarks/core"; + +const bookmarksFile: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [], +}; +const tagsFile: TagsFile = { version: 1, tags: {} }; + +function fakeClient(over: Partial = {}): GitHubClient { + const base: any = { + read: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") return { data: bookmarksFile, sha: "b1", etag: '"b"' }; + if (path === "tags.json") return { data: tagsFile, sha: "t1", etag: '"t"' }; + throw new Error("unexpected path"); + }), + readIfChanged: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }; + return Object.assign(base, over) as GitHubClient; +} + +describe("useGitmarksData", () => { + it("loads both files on mount", async () => { + const client = fakeClient(); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.bookmarksFile).toEqual(bookmarksFile); + expect(result.current.tagsFile).toEqual(tagsFile); + expect(result.current.error).toBeNull(); + }); + + it("refresh() uses readIfChanged with the stored etag and skips on 304", async () => { + const client = fakeClient(); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refresh(); + }); + + expect((client.readIfChanged as any)).toHaveBeenCalledWith("bookmarks.json", '"b"'); + expect((client.readIfChanged as any)).toHaveBeenCalledWith("tags.json", '"t"'); + }); + + it("refresh() applies a fresh result when ETag changes", async () => { + const updated: BookmarksFile = { ...bookmarksFile, updated_at: "2026-05-24T00:00:00Z" }; + const client = fakeClient({ + readIfChanged: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") return { data: updated, sha: "b2", etag: '"b2"' }; + return null; + }), + } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.bookmarksFile).toEqual(updated); + }); + + it("writeTags() calls client.update on tags.json with the mutator", async () => { + const update = vi.fn().mockResolvedValue({ data: tagsFile, sha: "t2", etag: '"t2"' }); + const client = fakeClient({ update } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const mutator = (f: TagsFile) => f; + await act(async () => { + await result.current.writeTags(mutator, "test commit"); + }); + + expect(update).toHaveBeenCalledWith("tags.json", mutator, "test commit"); + }); + + it("sets error when initial read throws", async () => { + const client = fakeClient({ + read: vi.fn().mockRejectedValue(new Error("boom")), + } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toMatch(/boom/); + expect(result.current.bookmarksFile).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/hooks.useGitmarksData.test.ts +``` + +Expected: FAIL — `Cannot find module ../src/hooks/useGitmarksData.js`. + +- [ ] **Step 3: Write the hook** + +Write `packages/web/src/hooks/useGitmarksData.ts`: + +```typescript +import { useCallback, useEffect, useRef, useState } from "react"; +import type { BookmarksFile, GitHubClient, TagsFile } from "@gitmarks/core"; + +interface Loaded { + data: T; + etag: string; + sha: string; +} + +export interface UseGitmarksData { + bookmarksFile: BookmarksFile | null; + tagsFile: TagsFile | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + writeTags: ( + mutate: (f: TagsFile) => TagsFile, + message: string, + ) => Promise; +} + +export function useGitmarksData(client: GitHubClient): UseGitmarksData { + const [bookmarks, setBookmarks] = useState | null>(null); + const [tags, setTags] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mounted = useRef(true); + + const loadInitial = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [b, t] = await Promise.all([ + client.read("bookmarks.json"), + client.read("tags.json").catch(() => null), + ]); + if (!mounted.current) return; + setBookmarks({ data: b.data, etag: b.etag, sha: b.sha }); + if (t != null) setTags({ data: t.data, etag: t.etag, sha: t.sha }); + else setTags({ data: { version: 1, tags: {} }, etag: "", sha: "" }); + } catch (err) { + if (!mounted.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mounted.current) setLoading(false); + } + }, [client]); + + const refresh = useCallback(async () => { + if (bookmarks == null) return loadInitial(); + setError(null); + try { + const [b, t] = await Promise.all([ + client.readIfChanged("bookmarks.json", bookmarks.etag), + tags != null && tags.etag.length > 0 + ? client.readIfChanged("tags.json", tags.etag) + : client.read("tags.json").catch(() => null), + ]); + if (!mounted.current) return; + if (b != null) setBookmarks({ data: b.data, etag: b.etag, sha: b.sha }); + if (t != null) setTags({ data: t.data, etag: t.etag, sha: t.sha }); + } catch (err) { + if (!mounted.current) return; + setError(err instanceof Error ? err.message : String(err)); + } + }, [bookmarks, tags, client, loadInitial]); + + const writeTags = useCallback( + async (mutate: (f: TagsFile) => TagsFile, message: string) => { + const result = await client.update("tags.json", mutate, message); + if (!mounted.current) return; + setTags({ data: result.data, etag: result.etag, sha: result.sha }); + }, + [client], + ); + + useEffect(() => { + mounted.current = true; + void loadInitial(); + return () => { + mounted.current = false; + }; + }, [loadInitial]); + + return { + bookmarksFile: bookmarks?.data ?? null, + tagsFile: tags?.data ?? null, + loading, + error, + refresh, + writeTags, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — 5 new tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/hooks/useGitmarksData.ts packages/web/test/hooks.useGitmarksData.test.ts +git commit -m "feat(web): useGitmarksData hook with ETag conditional refresh" +``` + +--- + +## Task 8: Search + filter helpers (pure) + +**Files:** +- Create: `packages/web/src/lib/data.ts` +- Create: `packages/web/test/lib.data.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Write `packages/web/test/lib.data.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import type { Bookmark, BookmarksFile } from "@gitmarks/core"; +import { searchBookmarks, visibleBookmarks, allUsedTags } from "../src/lib/data.js"; + +function mk(over: Partial = {}): Bookmark { + return { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0C1", + url: "https://example.com/article", + title: "Article", + folder: "", + tags: [], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: null, + notes: null, + ...over, + }; +} + +const file: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", title: "Hacker News", url: "https://news.ycombinator.com/", tags: ["daily"] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", title: "Lobsters", url: "https://lobste.rs/", tags: ["daily"] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", title: "Tailwind Docs", url: "https://tailwindcss.com/docs", tags: ["reference"], notes: "color tokens here" }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CD", title: "Tombstone", url: "https://gone.example.com/", deleted_at: "2026-05-10T00:00:00Z" }), + ], +}; + +describe("visibleBookmarks", () => { + it("filters out tombstoned bookmarks", () => { + expect(visibleBookmarks(file)).toHaveLength(3); + expect(visibleBookmarks(file).map((b) => b.id)).not.toContain("01HXYZ8K7M9P3RQ2V5W6Z8B0CD"); + }); +}); + +describe("searchBookmarks", () => { + it("returns all visible bookmarks for an empty query", () => { + expect(searchBookmarks(visibleBookmarks(file), "")).toHaveLength(3); + }); + + it("matches title case-insensitively", () => { + expect(searchBookmarks(visibleBookmarks(file), "tailwind")).toHaveLength(1); + expect(searchBookmarks(visibleBookmarks(file), "TAILWIND")).toHaveLength(1); + }); + + it("matches URL substring", () => { + expect(searchBookmarks(visibleBookmarks(file), "lobste.rs")).toHaveLength(1); + }); + + it("matches tags", () => { + expect(searchBookmarks(visibleBookmarks(file), "daily")).toHaveLength(2); + }); + + it("matches notes", () => { + expect(searchBookmarks(visibleBookmarks(file), "color tokens")).toHaveLength(1); + }); + + it("returns empty array for no matches", () => { + expect(searchBookmarks(visibleBookmarks(file), "unrelated-xyz")).toHaveLength(0); + }); + + it("trims whitespace from the query", () => { + expect(searchBookmarks(visibleBookmarks(file), " tailwind ")).toHaveLength(1); + }); +}); + +describe("allUsedTags", () => { + it("returns the set of tag names referenced by visible bookmarks", () => { + expect(allUsedTags(visibleBookmarks(file))).toEqual(new Set(["daily", "reference"])); + }); + + it("returns an empty set when no bookmarks have tags", () => { + expect(allUsedTags([])).toEqual(new Set()); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/lib.data.test.ts +``` + +Expected: FAIL — `Cannot find module ../src/lib/data.js`. + +- [ ] **Step 3: Write the implementation** + +Write `packages/web/src/lib/data.ts`: + +```typescript +import type { Bookmark, BookmarksFile } from "@gitmarks/core"; + +export function visibleBookmarks(file: BookmarksFile): Bookmark[] { + return file.bookmarks.filter((b) => b.deleted_at == null); +} + +export function searchBookmarks(bookmarks: Bookmark[], query: string): Bookmark[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return bookmarks; + return bookmarks.filter((b) => { + if (b.title.toLowerCase().includes(q)) return true; + if (b.url.toLowerCase().includes(q)) return true; + if (b.notes != null && b.notes.toLowerCase().includes(q)) return true; + return b.tags.some((t) => t.toLowerCase().includes(q)); + }); +} + +export function allUsedTags(bookmarks: Bookmark[]): Set { + const out = new Set(); + for (const b of bookmarks) for (const t of b.tags) out.add(t); + return out; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — 9 new tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/lib/data.ts packages/web/test/lib.data.test.ts +git commit -m "feat(web): pure search + visibility helpers" +``` + +--- + +## Task 9: List page rendering + +**Files:** +- Create: `packages/web/src/components/TagChip.tsx` +- Create: `packages/web/src/components/BookmarkRow.tsx` +- Create: `packages/web/src/components/BookmarkList.tsx` +- Modify: `packages/web/src/routes/ListPage.tsx` +- Create: `packages/web/test/components.BookmarkList.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/components.BookmarkList.test.tsx`: + +```typescript +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { BookmarkList } from "../src/components/BookmarkList.js"; + +const bookmarks: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", + url: "https://news.ycombinator.com/", + title: "Hacker News", + folder: "", + tags: ["daily"], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: null, + notes: null, + }, + { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", + url: "https://gone.example.com/", + title: "Deleted", + folder: "", + tags: [], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-10T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: "2026-05-10T00:00:00Z", + notes: null, + }, + ], +}; + +const tags: TagsFile = { + version: 1, + tags: { daily: { color: "#00FFFF", description: null } }, +}; + +describe("BookmarkList", () => { + it("renders one row per non-deleted bookmark", () => { + render(); + expect(screen.getByText("Hacker News")).toBeInTheDocument(); + expect(screen.queryByText("Deleted")).not.toBeInTheDocument(); + }); + + it("renders the URL as an external link", () => { + render(); + const link = screen.getByRole("link", { name: /hacker news/i }); + expect(link).toHaveAttribute("href", "https://news.ycombinator.com/"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders a tag chip per tag", () => { + render(); + expect(screen.getByText("daily")).toBeInTheDocument(); + }); + + it("renders an empty state when there are no visible bookmarks", () => { + const empty: BookmarksFile = { version: 1, updated_at: "now", bookmarks: [] }; + render(); + expect(screen.getByText(/no bookmarks yet/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/components.BookmarkList.test.tsx +``` + +Expected: FAIL — `Cannot find module ../src/components/BookmarkList.js`. + +- [ ] **Step 3: Write the TagChip** + +Write `packages/web/src/components/TagChip.tsx`: + +```typescript +import type { TagsFile } from "@gitmarks/core"; + +interface Props { + name: string; + tagsFile: TagsFile; +} + +const DEFAULT_COLOR = "#475569"; + +export function TagChip({ name, tagsFile }: Props) { + const tag = tagsFile.tags[name]; + const color = tag?.color ?? DEFAULT_COLOR; + return ( + + {name} + + ); +} +``` + +- [ ] **Step 4: Write the BookmarkRow** + +Write `packages/web/src/components/BookmarkRow.tsx`: + +```typescript +import type { Bookmark, TagsFile } from "@gitmarks/core"; +import { TagChip } from "./TagChip.js"; + +interface Props { + bookmark: Bookmark; + tagsFile: TagsFile; +} + +export function BookmarkRow({ bookmark, tagsFile }: Props) { + const folder = bookmark.folder.length > 0 ? bookmark.folder : "(root)"; + return ( +
  • +
    + + {bookmark.title} + + {folder} +
    +
    {bookmark.url}
    + {bookmark.tags.length > 0 && ( +
    + {bookmark.tags.map((t) => ( + + ))} +
    + )} + {bookmark.notes != null && ( +

    {bookmark.notes}

    + )} +
  • + ); +} +``` + +- [ ] **Step 5: Write the BookmarkList** + +Write `packages/web/src/components/BookmarkList.tsx`: + +```typescript +import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { BookmarkRow } from "./BookmarkRow.js"; +import { visibleBookmarks } from "../lib/data.js"; + +interface Props { + bookmarksFile: BookmarksFile; + tagsFile: TagsFile; +} + +export function BookmarkList({ bookmarksFile, tagsFile }: Props) { + const items = visibleBookmarks(bookmarksFile); + if (items.length === 0) { + return ( +

    + No bookmarks yet. Save one from a browser extension to see it here. +

    + ); + } + return ( +
      + {items.map((b) => ( + + ))} +
    + ); +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test test/components.BookmarkList.test.tsx +``` + +Expected: PASS — 4 new tests green. + +- [ ] **Step 7: Commit** + +```bash +git add packages/web/src/components/TagChip.tsx packages/web/src/components/BookmarkRow.tsx packages/web/src/components/BookmarkList.tsx packages/web/test/components.BookmarkList.test.tsx +git commit -m "feat(web): bookmark list rendering with tag chips" +``` + +--- + +## Task 10: Search bar + tag filter wiring (ListPage integration) + +**Files:** +- Create: `packages/web/src/components/SearchBar.tsx` +- Create: `packages/web/src/components/TagFilter.tsx` +- Modify: `packages/web/src/routes/ListPage.tsx` +- Modify: `packages/web/src/App.tsx` +- Create: `packages/web/test/ListPage.integration.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/ListPage.integration.test.tsx`: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import type { BookmarksFile, GitHubClient, TagsFile } from "@gitmarks/core"; +import { ListPage } from "../src/routes/ListPage.js"; + +const bookmarksFile: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", url: "https://news.ycombinator.com/", title: "Hacker News", folder: "", tags: ["daily"], added_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", added_from: "chrome@minerva", deleted_at: null, notes: null }, + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", url: "https://lobste.rs/", title: "Lobsters", folder: "", tags: ["daily"], added_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", added_from: "chrome@minerva", deleted_at: null, notes: null }, + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", url: "https://tailwindcss.com/docs", title: "Tailwind", folder: "", tags: ["reference"], added_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", added_from: "chrome@minerva", deleted_at: null, notes: null }, + ], +}; + +const tagsFile: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: null }, + reference: { color: "#00FF88", description: null }, + }, +}; + +function fakeClient(): GitHubClient { + return { + read: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") return { data: bookmarksFile, sha: "b", etag: '"b"' }; + if (path === "tags.json") return { data: tagsFile, sha: "t", etag: '"t"' }; + throw new Error("unexpected"); + }), + readIfChanged: vi.fn().mockResolvedValue(null), + update: vi.fn(), + } as any; +} + +describe("ListPage integration", () => { + it("filters the list when the user types in the search box", async () => { + const user = userEvent.setup(); + render( + + + , + ); + expect(await screen.findByText("Hacker News")).toBeInTheDocument(); + await user.type(screen.getByLabelText(/search/i), "tailwind"); + expect(screen.getByText("Tailwind")).toBeInTheDocument(); + expect(screen.queryByText("Hacker News")).not.toBeInTheDocument(); + }); + + it("filters the list when a tag chip is clicked in the sidebar", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await screen.findByText("Hacker News"); + await user.click(screen.getByRole("button", { name: /^daily$/i })); + expect(screen.getByText("Hacker News")).toBeInTheDocument(); + expect(screen.getByText("Lobsters")).toBeInTheDocument(); + expect(screen.queryByText("Tailwind")).not.toBeInTheDocument(); + }); + + it("clears the tag filter when the same chip is clicked again", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await screen.findByText("Hacker News"); + const chip = screen.getByRole("button", { name: /^daily$/i }); + await user.click(chip); + await user.click(chip); + expect(screen.getByText("Tailwind")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/ListPage.integration.test.tsx +``` + +Expected: FAIL — ListPage does not accept `client` prop and SearchBar/TagFilter do not exist. + +- [ ] **Step 3: Write the SearchBar** + +Write `packages/web/src/components/SearchBar.tsx`: + +```typescript +interface Props { + value: string; + onChange: (next: string) => void; +} + +export function SearchBar({ value, onChange }: Props) { + return ( + + ); +} +``` + +- [ ] **Step 4: Write the TagFilter** + +Write `packages/web/src/components/TagFilter.tsx`: + +```typescript +import type { TagsFile } from "@gitmarks/core"; + +interface Props { + used: Set; + tagsFile: TagsFile; + selected: string | null; + onSelect: (name: string | null) => void; +} + +const DEFAULT_COLOR = "#475569"; + +export function TagFilter({ used, tagsFile, selected, onSelect }: Props) { + const names = [...used].sort(); + if (names.length === 0) { + return

    no tags in use

    ; + } + return ( +
      + {names.map((name) => { + const color = tagsFile.tags[name]?.color ?? DEFAULT_COLOR; + const isSelected = selected === name; + return ( +
    • + +
    • + ); + })} +
    + ); +} +``` + +- [ ] **Step 5: Rewrite the ListPage with full wiring** + +Overwrite `packages/web/src/routes/ListPage.tsx`: + +```typescript +import { useMemo, useState } from "react"; +import type { GitHubClient } from "@gitmarks/core"; +import { useGitmarksData } from "../hooks/useGitmarksData.js"; +import { BookmarkList } from "../components/BookmarkList.js"; +import { SearchBar } from "../components/SearchBar.js"; +import { TagFilter } from "../components/TagFilter.js"; +import { Layout, type LayoutStatus } from "../components/Layout.js"; +import { allUsedTags, searchBookmarks, visibleBookmarks } from "../lib/data.js"; + +interface Props { + client: GitHubClient; +} + +export function ListPage({ client }: Props) { + const { bookmarksFile, tagsFile, loading, error, refresh } = useGitmarksData(client); + const [query, setQuery] = useState(""); + const [selectedTag, setSelectedTag] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const visible = useMemo( + () => (bookmarksFile != null ? visibleBookmarks(bookmarksFile) : []), + [bookmarksFile], + ); + const tagFiltered = useMemo( + () => (selectedTag == null ? visible : visible.filter((b) => b.tags.includes(selectedTag))), + [visible, selectedTag], + ); + const searched = useMemo( + () => searchBookmarks(tagFiltered, query), + [tagFiltered, query], + ); + const used = useMemo(() => allUsedTags(visible), [visible]); + + const status: LayoutStatus = loading + ? { kind: "loading", message: "loading…" } + : error != null + ? { kind: "err", message: error } + : { kind: "ok", message: `${visible.length} bookmarks` }; + + const filteredFile = bookmarksFile != null + ? { ...bookmarksFile, bookmarks: searched } + : null; + + async function onRefresh() { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + } + + return ( + +
    + +
    + + {filteredFile != null && tagsFile != null && ( +
    + +
    + )} +
    +
    +
    + ); +} +``` + +- [ ] **Step 6: Update App.tsx to pass the client** + +Overwrite `packages/web/src/App.tsx`: + +```typescript +import { + createHashRouter, + Navigate, + Outlet, + RouterProvider, + useOutletContext, +} from "react-router-dom"; +import { useMemo } from "react"; +import { loadSettings, type Settings } from "./lib/settings.js"; +import { makeClient } from "./lib/client.js"; +import type { GitHubClient } from "@gitmarks/core"; +import { SetupPage } from "./routes/SetupPage.js"; +import { ListPage } from "./routes/ListPage.js"; +import { TagsPage } from "./routes/TagsPage.js"; + +interface AppContext { + settings: Settings; + client: GitHubClient; +} + +function RequireSettings() { + const settings = loadSettings(); + if (settings == null) return ; + const client = makeClient(settings); + const ctx: AppContext = { settings, client }; + return ; +} + +export function useAppContext(): AppContext { + return useOutletContext(); +} + +function ListPageWithContext() { + const { client } = useAppContext(); + return ; +} + +function TagsPageWithContext() { + const { client } = useAppContext(); + return ; +} + +export function App() { + const router = useMemo( + () => + createHashRouter([ + { path: "/setup", element: }, + { + element: , + children: [ + { path: "/", element: }, + { path: "/tags", element: }, + ], + }, + ]), + [], + ); + return ; +} +``` + +Note: `TagsPage` will need a `client` prop too — placeholder still works as-is until Task 12 rewrites it. Update the placeholder to accept the prop (silently). Overwrite `packages/web/src/routes/TagsPage.tsx`: + +```typescript +import type { GitHubClient } from "@gitmarks/core"; + +interface Props { + client: GitHubClient; +} + +export function TagsPage(_props: Props) { + return ( +
    +

    Tags

    +
    + ); +} +``` + +- [ ] **Step 7: Update App.routing.test.tsx to match the new shape** + +The App now constructs a real `GitHubClient` per request, so the routing test must mock `globalThis.fetch` with shape-correct payloads for `bookmarks.json` AND `tags.json` (the TagsPage iterates `tagsFile.tags`, which crashes if the response is bookmarks-shaped). Replace the entire file: + +```typescript +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { App } from "../src/App.js"; +import { saveSettings } from "../src/lib/settings.js"; + +const validSettings = { + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", +}; + +function encodeContents(payload: unknown): string { + return btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); +} + +function makeFetchMock() { + return vi.fn().mockImplementation(async (url: RequestInfo | URL) => { + const href = typeof url === "string" ? url : url instanceof URL ? url.href : (url as Request).url; + if (href.includes("/contents/bookmarks.json")) { + return new Response( + JSON.stringify({ + content: encodeContents({ version: 1, updated_at: "2026-05-23T00:00:00Z", bookmarks: [] }), + sha: "b", + encoding: "base64", + }), + { status: 200, headers: { etag: '"b"' } }, + ); + } + if (href.includes("/contents/tags.json")) { + return new Response( + JSON.stringify({ + content: encodeContents({ version: 1, tags: {} }), + sha: "t", + encoding: "base64", + }), + { status: 200, headers: { etag: '"t"' } }, + ); + } + return new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }); + }); +} + +describe("App routing", () => { + beforeEach(() => { + localStorage.clear(); + window.location.hash = ""; + vi.stubGlobal("fetch", makeFetchMock()); + }); + + it("redirects to /setup when no settings are stored", async () => { + render(); + expect(await screen.findByRole("heading", { name: /set up gitmarks/i })).toBeInTheDocument(); + }); + + it("renders the list page when settings are present", async () => { + saveSettings(validSettings); + render(); + expect(await screen.findByTestId("list-page")).toBeInTheDocument(); + }); + + it("renders the tags page at /tags", async () => { + saveSettings(validSettings); + window.location.hash = "#/tags"; + render(); + expect(await screen.findByTestId("tags-page")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 8: Run all tests to verify they pass** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — all previous + 3 new integration tests + updated routing tests. + +- [ ] **Step 9: Commit** + +```bash +git add packages/web/src/components/SearchBar.tsx packages/web/src/components/TagFilter.tsx packages/web/src/routes/ListPage.tsx packages/web/src/routes/TagsPage.tsx packages/web/src/App.tsx packages/web/test/ListPage.integration.test.tsx packages/web/test/App.routing.test.tsx +git commit -m "feat(web): list page with live search and tag filter sidebar" +``` + +--- + +## Task 11: Tag mutation helpers (pure) + +**Files:** +- Create: `packages/web/src/lib/tag-mutations.ts` +- Create: `packages/web/test/lib.tag-mutations.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Write `packages/web/test/lib.tag-mutations.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import type { TagsFile } from "@gitmarks/core"; +import { addTag, deleteTag, renameTag, setTagColor } from "../src/lib/tag-mutations.js"; + +const file: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: "open every morning" }, + "to-read": { color: "#FFFF00", description: null }, + }, +}; + +describe("addTag", () => { + it("adds a new tag", () => { + const next = addTag(file, "reference", "#00FF88", "docs and refs"); + expect(next.tags["reference"]).toEqual({ color: "#00FF88", description: "docs and refs" }); + }); + + it("does not mutate the input", () => { + addTag(file, "reference", "#00FF88", null); + expect(file.tags["reference"]).toBeUndefined(); + }); + + it("throws when adding a tag that already exists", () => { + expect(() => addTag(file, "daily", "#FF0000", null)).toThrow(/already exists/); + }); + + it("rejects invalid color format", () => { + expect(() => addTag(file, "x", "red", null)).toThrow(/color/i); + }); + + it("rejects empty name", () => { + expect(() => addTag(file, "", "#FFFFFF", null)).toThrow(/name/i); + }); +}); + +describe("setTagColor", () => { + it("updates the color of an existing tag", () => { + const next = setTagColor(file, "daily", "#123456"); + expect(next.tags["daily"]?.color).toBe("#123456"); + expect(next.tags["daily"]?.description).toBe("open every morning"); + }); + + it("throws when the tag doesn't exist", () => { + expect(() => setTagColor(file, "missing", "#FFFFFF")).toThrow(/not found/); + }); + + it("rejects invalid color format", () => { + expect(() => setTagColor(file, "daily", "purple")).toThrow(/color/i); + }); +}); + +describe("renameTag", () => { + it("renames a tag entry", () => { + const next = renameTag(file, "to-read", "queue"); + expect(next.tags["queue"]).toEqual(file.tags["to-read"]); + expect(next.tags["to-read"]).toBeUndefined(); + }); + + it("does not mutate the input", () => { + renameTag(file, "to-read", "queue"); + expect(file.tags["to-read"]).toBeDefined(); + }); + + it("throws when source doesn't exist", () => { + expect(() => renameTag(file, "missing", "x")).toThrow(/not found/); + }); + + it("throws when destination already exists", () => { + expect(() => renameTag(file, "daily", "to-read")).toThrow(/already exists/); + }); + + it("no-ops when old and new names are identical", () => { + expect(renameTag(file, "daily", "daily")).toEqual(file); + }); +}); + +describe("deleteTag", () => { + it("removes a tag entry", () => { + const next = deleteTag(file, "daily"); + expect(next.tags["daily"]).toBeUndefined(); + expect(next.tags["to-read"]).toBeDefined(); + }); + + it("throws when the tag doesn't exist", () => { + expect(() => deleteTag(file, "missing")).toThrow(/not found/); + }); + + it("does not mutate the input", () => { + deleteTag(file, "daily"); + expect(file.tags["daily"]).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/lib.tag-mutations.test.ts +``` + +Expected: FAIL — `Cannot find module ../src/lib/tag-mutations.js`. + +- [ ] **Step 3: Write the implementation** + +Write `packages/web/src/lib/tag-mutations.ts`: + +```typescript +import type { TagsFile } from "@gitmarks/core"; + +const COLOR_RE = /^#[0-9A-Fa-f]{6}$/; + +function assertColor(color: string): void { + if (!COLOR_RE.test(color)) { + throw new Error(`invalid color (expected #RRGGBB, got "${color}")`); + } +} + +function assertName(name: string): void { + if (name.length === 0) throw new Error("tag name must not be empty"); +} + +export function addTag( + file: TagsFile, + name: string, + color: string, + description: string | null, +): TagsFile { + assertName(name); + assertColor(color); + if (file.tags[name] !== undefined) { + throw new Error(`tag "${name}" already exists`); + } + return { ...file, tags: { ...file.tags, [name]: { color, description } } }; +} + +export function setTagColor(file: TagsFile, name: string, color: string): TagsFile { + assertColor(color); + const existing = file.tags[name]; + if (existing === undefined) throw new Error(`tag "${name}" not found`); + return { + ...file, + tags: { ...file.tags, [name]: { ...existing, color } }, + }; +} + +export function renameTag(file: TagsFile, oldName: string, newName: string): TagsFile { + if (oldName === newName) return file; + assertName(newName); + const existing = file.tags[oldName]; + if (existing === undefined) throw new Error(`tag "${oldName}" not found`); + if (file.tags[newName] !== undefined) { + throw new Error(`tag "${newName}" already exists`); + } + const next = { ...file.tags }; + delete next[oldName]; + next[newName] = existing; + return { ...file, tags: next }; +} + +export function deleteTag(file: TagsFile, name: string): TagsFile { + if (file.tags[name] === undefined) throw new Error(`tag "${name}" not found`); + const next = { ...file.tags }; + delete next[name]; + return { ...file, tags: next }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — 16 new tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/lib/tag-mutations.ts packages/web/test/lib.tag-mutations.test.ts +git commit -m "feat(web): pure tag mutation helpers" +``` + +--- + +## Task 12: Tag manager UI (writes to tags.json) + +**Files:** +- Create: `packages/web/src/components/TagManager.tsx` +- Modify: `packages/web/src/routes/TagsPage.tsx` +- Create: `packages/web/test/components.TagManager.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Write `packages/web/test/components.TagManager.test.tsx`: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { TagsFile } from "@gitmarks/core"; +import { TagManager } from "../src/components/TagManager.js"; + +const tagsFile: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: "open every morning" }, + "to-read": { color: "#FFFF00", description: null }, + }, +}; + +describe("TagManager", () => { + it("lists existing tags", () => { + render(); + expect(screen.getByDisplayValue("daily")).toBeInTheDocument(); + expect(screen.getByDisplayValue("to-read")).toBeInTheDocument(); + }); + + it("calls onMutate with a renaming mutator when the name input is committed", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + const input = screen.getByDisplayValue("daily"); + await user.clear(input); + await user.type(input, "morning"); + await user.tab(); + expect(onMutate).toHaveBeenCalledOnce(); + const mutator = onMutate.mock.calls[0]![0]; + const next = mutator(tagsFile); + expect(next.tags["morning"]).toBeDefined(); + expect(next.tags["daily"]).toBeUndefined(); + }); + + it("calls onMutate with a color mutator when the color input changes", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + const colorInput = screen.getByLabelText(/color for daily/i) as HTMLInputElement; + await user.click(colorInput); + // jsdom doesn't fire change for color inputs reliably; use fireEvent via library + colorInput.value = "#123456"; + colorInput.dispatchEvent(new Event("change", { bubbles: true })); + expect(onMutate).toHaveBeenCalled(); + const mutator = onMutate.mock.calls[0]![0]; + expect(mutator(tagsFile).tags["daily"]?.color).toBe("#123456"); + }); + + it("calls onMutate with a delete mutator when the delete button is clicked", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /delete daily/i })); + const mutator = onMutate.mock.calls[0]![0]; + expect(mutator(tagsFile).tags["daily"]).toBeUndefined(); + }); + + it("adds a new tag through the new-tag row", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText(/new tag name/i), "reference"); + await user.click(screen.getByRole("button", { name: /add tag/i })); + const mutator = onMutate.mock.calls[0]![0]; + expect(mutator(tagsFile).tags["reference"]).toEqual({ color: "#22d3ee", description: null }); + }); + + it("surfaces a validation error inline without calling onMutate", async () => { + const onMutate = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByDisplayValue("daily"); + await user.clear(input); + await user.type(input, "to-read"); + await user.tab(); + expect(screen.getByText(/already exists/i)).toBeInTheDocument(); + expect(onMutate).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --filter @gitmarks/web test test/components.TagManager.test.tsx +``` + +Expected: FAIL — `Cannot find module ../src/components/TagManager.js`. + +- [ ] **Step 3: Write the TagManager** + +Write `packages/web/src/components/TagManager.tsx`: + +```typescript +import { useState } from "react"; +import type { TagsFile } from "@gitmarks/core"; +import { addTag, deleteTag, renameTag, setTagColor } from "../lib/tag-mutations.js"; + +type Mutator = (file: TagsFile) => TagsFile; + +interface Props { + tagsFile: TagsFile; + onMutate: (mutator: Mutator) => Promise; +} + +export function TagManager({ tagsFile, onMutate }: Props) { + const [error, setError] = useState(null); + const [newName, setNewName] = useState(""); + + async function safeMutate(mutator: Mutator): Promise { + setError(null); + try { + mutator(tagsFile); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return; + } + await onMutate(mutator); + } + + return ( +
    +

    Tags

    +

    + Renaming a tag updates tags.json only; existing bookmarks still reference the old name. +

    + {error &&

    {error}

    } + +
      + {Object.entries(tagsFile.tags).map(([name, tag]) => ( + safeMutate((f) => renameTag(f, name, next))} + onColor={(next) => safeMutate((f) => setTagColor(f, name, next))} + onDelete={() => safeMutate((f) => deleteTag(f, name))} + /> + ))} +
    + +
    + + +
    +
    + ); +} + +interface RowProps { + name: string; + color: string; + onRename: (next: string) => Promise; + onColor: (next: string) => Promise; + onDelete: () => Promise; +} + +function TagRow({ name, color, onRename, onColor, onDelete }: RowProps) { + const [draft, setDraft] = useState(name); + return ( +
  • + { void onColor(e.target.value); }} + className="w-8 h-8 bg-transparent border border-fog rounded cursor-pointer" + /> + setDraft(e.target.value)} + onBlur={() => { if (draft !== name) void onRename(draft); }} + className="flex-1 px-3 py-2 bg-mist border border-fog rounded text-cyan-soft focus:border-cyan focus:outline-none" + /> + +
  • + ); +} +``` + +- [ ] **Step 4: Wire the TagManager into the TagsPage** + +Overwrite `packages/web/src/routes/TagsPage.tsx`: + +```typescript +import { useState } from "react"; +import type { GitHubClient, TagsFile } from "@gitmarks/core"; +import { TagManager } from "../components/TagManager.js"; +import { Layout, type LayoutStatus } from "../components/Layout.js"; +import { useGitmarksData } from "../hooks/useGitmarksData.js"; + +interface Props { + client: GitHubClient; +} + +export function TagsPage({ client }: Props) { + const { tagsFile, loading, error, refresh, writeTags } = useGitmarksData(client); + const [refreshing, setRefreshing] = useState(false); + const [writeError, setWriteError] = useState(null); + + const status: LayoutStatus = loading + ? { kind: "loading", message: "loading…" } + : writeError != null + ? { kind: "err", message: writeError } + : error != null + ? { kind: "err", message: error } + : tagsFile != null + ? { kind: "ok", message: `${Object.keys(tagsFile.tags).length} tags` } + : { kind: "loading", message: "loading…" }; + + async function onRefresh() { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + } + + async function onMutate(mutator: (f: TagsFile) => TagsFile) { + setWriteError(null); + try { + await writeTags(mutator, "web: update tags"); + } catch (err) { + setWriteError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + +
    + {tagsFile != null && } +
    +
    + ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +pnpm --filter @gitmarks/web test +``` + +Expected: PASS — 6 new TagManager tests + previous = all green. + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/components/TagManager.tsx packages/web/src/routes/TagsPage.tsx packages/web/test/components.TagManager.test.tsx +git commit -m "feat(web): tag manager UI writing to tags.json" +``` + +--- + +## Task 13: Docs, root README + CLAUDE.md updates, CI verification + +**Files:** +- Create: `packages/web/README.md` +- Modify: `README.md` +- Modify: `CLAUDE.md` +- Verify: `.github/workflows/test.yml` + +- [ ] **Step 1: Write the package README** + +Write `packages/web/README.md`: + +```markdown +# @gitmarks/web + +Static SPA for browsing and tagging your gitmarks. Vite + React + Tailwind. +Reads `bookmarks.json` and `tags.json` directly from GitHub via the Contents +API; no server. + +## Develop + +```bash +pnpm --filter @gitmarks/web dev +``` + +The dev server runs at `http://localhost:5173/`. Hash routes: + +- `#/setup` — PAT + owner + repo + branch entry, with a Validate step +- `#/` — list page (search + tag filter sidebar) +- `#/tags` — tag manager (rename, recolor, add, delete) + +On first load with no settings stored, the router redirects to `#/setup`. + +## Build + +```bash +pnpm --filter @gitmarks/web build +``` + +The output lands in `packages/web/dist/`. `base: "./"` is set so the build +works under any path — drop the folder onto GitHub Pages or Cloudflare Pages. + +## Manual smoke test + +After running `pnpm --filter @gitmarks/web dev`: + +- [ ] Open `http://localhost:5173/` — the app redirects to `#/setup`. +- [ ] Enter a valid fine-grained PAT (Contents: read/write on your bookmarks + repo), owner, repo, branch. Click **Validate** → green confirmation. +- [ ] Click **Save** → the app redirects to the list view. +- [ ] If the repo has bookmarks, they render with tag chips and folder labels. +- [ ] Type in the search box — the list filters live. +- [ ] Click a tag in the sidebar — only bookmarks with that tag remain. + Click the same tag again to clear the filter. +- [ ] Click **Sync from GitHub** — the status pill briefly says "Syncing…" + then returns to the bookmark count. If you edit `bookmarks.json` + directly on github.com first, the new entry appears after the sync. +- [ ] Open `#/tags`. Rename a tag, change its color, add a new tag, delete + a tag. Each action commits to `tags.json` immediately. Refresh the + page and confirm the changes persisted. + +## Scope (v1) + +Read-side only. Bookmark creation, editing, bulk operations, trash view, and +Netscape HTML export are tracked separately as [#25 Web UI v2](https://github.com/paperhurts/gitmarks/issues/25). + +## Architecture + +``` +src/ + main.tsx # React entry + App.tsx # RouterProvider; settings gate via + index.css # Tailwind directives + lib/ + settings.ts # localStorage wrapper with Zod validation + client.ts # GitHubClient factory + validateConnection + data.ts # pure helpers: visibleBookmarks, searchBookmarks, allUsedTags + tag-mutations.ts # pure helpers: addTag/renameTag/setTagColor/deleteTag + hooks/ + useGitmarksData.ts # loads both files with ETag; refresh + writeTags + components/ + Layout.tsx, SetupForm.tsx, BookmarkList.tsx, BookmarkRow.tsx, + TagChip.tsx, SearchBar.tsx, TagFilter.tsx, TagManager.tsx + routes/ + SetupPage.tsx, ListPage.tsx, TagsPage.tsx +``` + +The page-level components own data + state; the dumb components (BookmarkRow, +TagChip, SearchBar, TagFilter, TagManager) take props and emit callbacks. +Writes go through `client.update()` from `@gitmarks/core`, which transparently +handles 409 retry-replay. + +## Deploying to GitHub Pages + +```bash +pnpm --filter @gitmarks/web build +# copy packages/web/dist/ into the gh-pages branch of any repo, or use the +# `gh-pages` npm package to push. +``` + +Because `base: "./"` is set, the build works at any path. +``` + +- [ ] **Step 2: Update the root README** + +Read `README.md`. Append a row to the Packages table and update the roadmap. + +In the `## Packages` table, append a new row directly after the existing `@gitmarks/extension-firefox` row. Use the Edit tool with a precise `old_string` that includes the closing `|` of the firefox row plus the trailing newline. New row: + +```markdown +| `@gitmarks/web` | Static SPA — list, search, tag management. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. | +``` + +In the `## Roadmap` section, change: + +``` +- ⬜ Web UI v1: list + search + tag management ([#24](https://github.com/paperhurts/gitmarks/issues/24)) +``` + +to: + +``` +- ✅ Web UI v1: list + search + tag management ([#24](https://github.com/paperhurts/gitmarks/issues/24)) +``` + +Also update the `## Architecture` ASCII diagram by removing the `(planned)` tag from `Web UI`: + +``` +[Chrome ext] [Firefox ext] [Safari ext (planned)] [Web UI] +``` + +Use the Edit tool for each of these — exact strings, no replace_all. + +- [ ] **Step 3: Update CLAUDE.md** + +Read `CLAUDE.md`. Find the package list section and add the web package. Find the roadmap and check off Web UI v1. Use Edit tool to make each change as a precise string replacement. Example structure (adapt to actual current content): + +- Add `@gitmarks/web` to the package map with: "Static SPA — list, search, tag management. Vite + React + Tailwind." +- Change `⬜ Web UI v1` → `✅ Web UI v1`. + +- [ ] **Step 4: Verify CI picks up the new package** + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm build +``` + +Expected: all four succeed. The CI workflow (`.github/workflows/test.yml`) runs these same commands at the root, so no workflow change is required. The web package's `vite build` runs `tsc --noEmit && vite build` per its `build` script. + +If any fails, fix on the branch before continuing. + +- [ ] **Step 5: Commit docs + verify final state** + +```bash +git add packages/web/README.md README.md CLAUDE.md +git commit -m "docs(web): add package README and update root docs" +``` + +Then verify the full local pipeline one more time: + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +Expected: green. + +--- + +## Final Verification + +- [ ] **Run the full test suite from repo root** + +```bash +pnpm test +``` + +Expected: all packages green. Web should contribute ~40 new unit + component tests. + +- [ ] **Run a fresh dev server and walk the manual smoke test in `packages/web/README.md`** + +```bash +pnpm --filter @gitmarks/web dev +``` + +Open `http://localhost:5173/` in a browser. Walk the smoke-test checklist using the real `paperhurts/gitmarks-bookmarks` repo (or whichever repo the user uses) with a fine-grained PAT. + +If anything is broken in the live UI but tests pass, that's a wiring gap — file as a follow-up issue and document, since unit tests miss real-world data shapes. + +- [ ] **Open the PR** + +When all tasks are complete, follow `superpowers:finishing-a-development-branch`. The recommended choice is Option 2 (push + PR). + +```bash +git push -u origin feat/web-ui-v1 +gh pr create --title "feat(web): web UI v1 — list, search, tag management" --body "$(cat <<'EOF' +## Summary +- New `@gitmarks/web` package: Vite + React + Tailwind SPA +- List + client-side search + tag filter sidebar +- Setup flow with PAT validation, settings persisted in localStorage +- Tag manager (rename, recolor, add, delete) writing to `tags.json` only + +Closes #24. + +## Test plan +- [x] Unit + component suite green via Vitest + @testing-library/react +- [ ] Manual smoke test (see `packages/web/README.md`) +- [ ] Build succeeds with `pnpm --filter @gitmarks/web build` and serves from `dist/` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +CI will run on push. Wait for green, then merge with a merge commit (per `CONTRIBUTING.md`). + +--- + +## Spec Cross-Reference + +| Spec requirement | Task | +|---|---| +| Static SPA, Cloudflare/GH Pages | Task 1 (base: "./" + hash routing) | +| React + Vite + Tailwind, cyan/magenta on dark | Task 1 | +| Same PAT model as extension, paste once, localStorage | Task 2 | +| Talks to GitHub Contents API directly | Task 3 + Task 7 (via `@gitmarks/core`) | +| Same conflict logic as extension | Task 12 (`client.update()` retries) | +| List + search (client-side, in memory) | Task 8 (helpers), Task 9 (UI), Task 10 (search bar) | +| Tag management | Task 11 (helpers), Task 12 (UI) | +| Manual "sync from GitHub" | Task 6 (button), Task 7 (refresh), Task 10 (wiring) | +| Bulk operations | Out of scope (#25) | +| Trash view | Out of scope (#25) | +| Export to Netscape HTML | Out of scope (#25) | +| No bookmark creation in web UI v1 | Honored — no creation surface anywhere | +| Tombstones hidden | Task 8 (`visibleBookmarks` filter) | +| Renaming a tag doesn't churn bookmarks | Task 11 (`renameTag` operates on `TagsFile` only) | + +## Notes for the implementer + +- Every task ends with a passing test run and a commit. Do not skip the commit step — the per-task history is reviewed (per `CONTRIBUTING.md`). +- The strict TypeScript settings in `tsconfig.base.json` are non-negotiable: `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`. Patterns to remember: + - Index access on `Record` returns `V | undefined` — always check for `undefined` before using. + - Optional props need conditional spread, not `prop: maybeUndefined`. Example in Task 3: `...(fetchImpl !== undefined ? { fetch: fetchImpl } : {})`. + - All relative imports use the `.js` suffix (e.g. `import { X } from "./lib/foo.js"`) even though the source is `.ts`. +- Component tests use `@testing-library/react` + `userEvent` from `@testing-library/user-event`. Use `findBy*` for anything that depends on async state from `useGitmarksData`. +- When a task asks you to overwrite a file you wrote in an earlier task, use the Write tool — the test will catch any regression. +- If you hit an `exactOptionalPropertyTypes` error around the GitHubClient's `fetch` option, use the conditional-spread pattern from Task 3. diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 5b2d9fe..47ffbe3 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -10,6 +10,9 @@ import { SetupPage } from "./routes/SetupPage.js"; import { ListPage } from "./routes/ListPage.js"; import { TagsPage } from "./routes/TagsPage.js"; +// Exported for tests, which compose under a MemoryRouter to +// sidestep a Node 24 / undici / jsdom AbortSignal incompatibility that breaks +// createHashRouter under test. Production wiring is in App() below. export function RequireSettings() { const settings = loadSettings(); if (settings == null) return ; diff --git a/packages/web/test/setup.ts b/packages/web/test/setup.ts index 9c5387b..3ab316d 100644 --- a/packages/web/test/setup.ts +++ b/packages/web/test/setup.ts @@ -4,13 +4,14 @@ import { cleanup } from "@testing-library/react"; // React Router's hash history listener fires a navigation when window.location.hash // changes in jsdom. That navigation calls `new Request()` whose AbortSignal is a jsdom -// instance that undici doesn't recognise. Silence those specific rejections so they -// don't cause false-positive test failures. +// instance that undici doesn't recognise. Silence that specific rejection so it +// doesn't cause false-positive test failures. process.on("unhandledRejection", (reason) => { if ( reason instanceof TypeError && typeof reason.message === "string" && - reason.message.includes("AbortSignal") + reason.message.includes('Expected signal ("AbortSignal') && + reason.message.includes("to be an instance of AbortSignal") ) { return; } diff --git a/packages/web/test/smoke.test.tsx b/packages/web/test/smoke.test.tsx deleted file mode 100644 index 7058664..0000000 --- a/packages/web/test/smoke.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { MemoryRouter, Routes, Route } from "react-router-dom"; -import { RequireSettings } from "../src/App.js"; -import { SetupPage } from "../src/routes/SetupPage.js"; - -describe("App", () => { - it("renders the gitmarks heading", async () => { - render( - - - } /> - }> - Home} /> - - - , - ); - expect(await screen.findByRole("heading", { name: /gitmarks/i })).toBeInTheDocument(); - }); -}); From 3e65494cee443a9f38313f675a2cec6230404790 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:22:10 -0400 Subject: [PATCH 09/20] feat(web): setup form with PAT entry, validate, and persist Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/SetupForm.tsx | 140 ++++++++++++++++++ packages/web/src/routes/SetupPage.tsx | 8 +- .../web/test/components.SetupForm.test.tsx | 72 +++++++++ 3 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 packages/web/src/components/SetupForm.tsx create mode 100644 packages/web/test/components.SetupForm.test.tsx diff --git a/packages/web/src/components/SetupForm.tsx b/packages/web/src/components/SetupForm.tsx new file mode 100644 index 0000000..e4dff39 --- /dev/null +++ b/packages/web/src/components/SetupForm.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { saveSettings, type Settings } from "../lib/settings.js"; +import { validateConnection, type ValidateResult } from "../lib/client.js"; + +type ValidateFn = (settings: Settings) => Promise; + +interface Props { + validate?: ValidateFn; +} + +const labelClass = "block text-sm text-cyan-soft mb-1"; +const inputClass = + "w-full px-3 py-2 bg-mist border border-fog rounded text-cyan-soft focus:border-cyan focus:outline-none"; +const buttonClass = + "px-4 py-2 rounded bg-cyan text-ink font-semibold hover:bg-cyan-soft disabled:opacity-40 disabled:cursor-not-allowed"; + +export function SetupForm({ validate = validateConnection }: Props) { + const [token, setToken] = useState(""); + const [owner, setOwner] = useState(""); + const [repo, setRepo] = useState(""); + const [branch, setBranch] = useState("main"); + const [validating, setValidating] = useState(false); + const [validated, setValidated] = useState(false); + const [status, setStatus] = useState<{ kind: "ok" | "err"; message: string } | null>(null); + const navigate = useNavigate(); + + const settings: Settings = { token, owner, repo, branch }; + const formComplete = token.length > 0 && owner.length > 0 && repo.length > 0 && branch.length > 0; + + async function onValidate() { + setValidating(true); + setValidated(false); + setStatus(null); + const result = await validate(settings); + setValidating(false); + if (result.status === "ok-with-files") { + setStatus({ kind: "ok", message: "✓ valid PAT, repo + bookmarks.json found" }); + setValidated(true); + } else if (result.status === "ok-no-files") { + setStatus({ kind: "ok", message: "✓ valid PAT, repo exists (bookmarks.json will be created on first save)" }); + setValidated(true); + } else if (result.status === "auth-failed") { + setStatus({ kind: "err", message: "Invalid token — check PAT permissions" }); + } else if (result.status === "repo-not-found") { + setStatus({ kind: "err", message: "Repo not found — check owner/repo/branch" }); + } else { + setStatus({ kind: "err", message: `Network error: ${result.message}` }); + } + } + + function onSave() { + saveSettings(settings); + navigate("/"); + } + + return ( +
    { + e.preventDefault(); + if (validated) onSave(); + }} + > +

    Set up gitmarks

    + + + + + + + + + +
    + + +
    + + {status && ( +

    + {status.message} +

    + )} +
    + ); +} diff --git a/packages/web/src/routes/SetupPage.tsx b/packages/web/src/routes/SetupPage.tsx index 83da5c3..873a3b6 100644 --- a/packages/web/src/routes/SetupPage.tsx +++ b/packages/web/src/routes/SetupPage.tsx @@ -1,7 +1,9 @@ +import { SetupForm } from "../components/SetupForm.js"; + export function SetupPage() { return ( -
    -

    Set up gitmarks

    -
    +
    + +
    ); } diff --git a/packages/web/test/components.SetupForm.test.tsx b/packages/web/test/components.SetupForm.test.tsx new file mode 100644 index 0000000..163fa8b --- /dev/null +++ b/packages/web/test/components.SetupForm.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { SetupForm } from "../src/components/SetupForm.js"; +import { loadSettings } from "../src/lib/settings.js"; + +import type { ValidateResult } from "../src/lib/client.js"; + +type ValidateFn = (s: any) => Promise; + +function renderForm(validate: ValidateFn) { + return render( + + + , + ); +} + +describe("SetupForm", () => { + beforeEach(() => localStorage.clear()); + + it("disables Save until Validate succeeds", async () => { + const user = userEvent.setup(); + const validate = vi.fn().mockResolvedValue({ status: "ok-with-files" }); + renderForm(validate); + + await user.type(screen.getByLabelText(/token/i), "ghp_fake"); + await user.type(screen.getByLabelText(/owner/i), "paperhurts"); + await user.type(screen.getByLabelText(/^repo$/i), "bookmarks"); + + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: /validate/i })); + expect(await screen.findByText(/valid PAT/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeEnabled(); + }); + + it("shows auth-failed error when validate returns auth-failed", async () => { + const user = userEvent.setup(); + const validate = vi.fn().mockResolvedValue({ status: "auth-failed" }); + renderForm(validate); + + await user.type(screen.getByLabelText(/token/i), "bad"); + await user.type(screen.getByLabelText(/owner/i), "paperhurts"); + await user.type(screen.getByLabelText(/^repo$/i), "bookmarks"); + await user.click(screen.getByRole("button", { name: /validate/i })); + + expect(await screen.findByText(/invalid token/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + }); + + it("persists settings to localStorage on Save", async () => { + const user = userEvent.setup(); + const validate = vi.fn().mockResolvedValue({ status: "ok-with-files" }); + renderForm(validate); + + await user.type(screen.getByLabelText(/token/i), "ghp_fake"); + await user.type(screen.getByLabelText(/owner/i), "paperhurts"); + await user.type(screen.getByLabelText(/^repo$/i), "bookmarks"); + await user.click(screen.getByRole("button", { name: /validate/i })); + await screen.findByText(/valid PAT/i); + await user.click(screen.getByRole("button", { name: /save/i })); + + expect(loadSettings()).toEqual({ + token: "ghp_fake", + owner: "paperhurts", + repo: "bookmarks", + branch: "main", + }); + }); +}); From b73f176a452e06193377ab586f510e328aafdc69 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:24:47 -0400 Subject: [PATCH 10/20] feat(web): layout chrome with nav and status pill --- packages/web/src/components/Layout.tsx | 72 ++++++++++++++++++++ packages/web/test/components.Layout.test.tsx | 36 ++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/web/src/components/Layout.tsx create mode 100644 packages/web/test/components.Layout.test.tsx diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx new file mode 100644 index 0000000..c63c088 --- /dev/null +++ b/packages/web/src/components/Layout.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { NavLink } from "react-router-dom"; + +export type LayoutStatus = + | { kind: "ok"; message: string } + | { kind: "warn"; message: string } + | { kind: "err"; message: string } + | { kind: "loading"; message: string }; + +interface Props { + children: ReactNode; + status: LayoutStatus; + onRefresh: () => void; + refreshing: boolean; +} + +const navLinkBase = "px-3 py-1 rounded"; +const navLinkActive = "bg-fog text-cyan"; +const navLinkInactive = "text-cyan-soft hover:text-cyan"; + +export function Layout({ children, status, onRefresh, refreshing }: Props) { + return ( +
    +
    + gitmarks + +
    + + +
    +
    +
    {children}
    +
    + ); +} + +function StatusPill({ status }: { status: LayoutStatus }) { + const color = + status.kind === "ok" + ? "text-cyan" + : status.kind === "warn" + ? "text-yellow-300" + : status.kind === "err" + ? "text-magenta" + : "text-cyan-soft"; + return {status.message}; +} diff --git a/packages/web/test/components.Layout.test.tsx b/packages/web/test/components.Layout.test.tsx new file mode 100644 index 0000000..bb9ffbb --- /dev/null +++ b/packages/web/test/components.Layout.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { Layout } from "../src/components/Layout.js"; + +function rendered(initial = "/") { + return render( + + {}} + refreshing={false} + > +
    content
    +
    +
    , + ); +} + +describe("Layout", () => { + it("renders the children", () => { + rendered(); + expect(screen.getByTestId("outlet")).toBeInTheDocument(); + }); + + it("renders nav links for List and Tags", () => { + rendered(); + expect(screen.getByRole("link", { name: /list/i })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: /tags/i })).toHaveAttribute("href", "/tags"); + }); + + it("shows the status pill", () => { + rendered(); + expect(screen.getByText(/synced 12s ago/i)).toBeInTheDocument(); + }); +}); From 9bdb6b2d7a8641b27ec31b971cdfcb930fe68495 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:28:14 -0400 Subject: [PATCH 11/20] feat(web): useGitmarksData hook with ETag conditional refresh Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/hooks/useGitmarksData.ts | 93 +++++++++++++++++++ .../web/test/hooks.useGitmarksData.test.ts | 91 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 packages/web/src/hooks/useGitmarksData.ts create mode 100644 packages/web/test/hooks.useGitmarksData.test.ts diff --git a/packages/web/src/hooks/useGitmarksData.ts b/packages/web/src/hooks/useGitmarksData.ts new file mode 100644 index 0000000..e62a25f --- /dev/null +++ b/packages/web/src/hooks/useGitmarksData.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { BookmarksFile, GitHubClient, TagsFile } from "@gitmarks/core"; + +interface Loaded { + data: T; + etag: string; + sha: string; +} + +export interface UseGitmarksData { + bookmarksFile: BookmarksFile | null; + tagsFile: TagsFile | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + writeTags: ( + mutate: (f: TagsFile) => TagsFile, + message: string, + ) => Promise; +} + +export function useGitmarksData(client: GitHubClient): UseGitmarksData { + const [bookmarks, setBookmarks] = useState | null>(null); + const [tags, setTags] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mounted = useRef(true); + + const loadInitial = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [b, t] = await Promise.all([ + client.read("bookmarks.json"), + client.read("tags.json").catch(() => null), + ]); + if (!mounted.current) return; + setBookmarks({ data: b.data, etag: b.etag, sha: b.sha }); + if (t != null) setTags({ data: t.data, etag: t.etag, sha: t.sha }); + else setTags({ data: { version: 1, tags: {} }, etag: "", sha: "" }); + } catch (err) { + if (!mounted.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mounted.current) setLoading(false); + } + }, [client]); + + const refresh = useCallback(async () => { + if (bookmarks == null) return loadInitial(); + setError(null); + try { + const [b, t] = await Promise.all([ + client.readIfChanged("bookmarks.json", bookmarks.etag), + tags != null && tags.etag.length > 0 + ? client.readIfChanged("tags.json", tags.etag) + : client.read("tags.json").catch(() => null), + ]); + if (!mounted.current) return; + if (b != null) setBookmarks({ data: b.data, etag: b.etag, sha: b.sha }); + if (t != null) setTags({ data: t.data, etag: t.etag, sha: t.sha }); + } catch (err) { + if (!mounted.current) return; + setError(err instanceof Error ? err.message : String(err)); + } + }, [bookmarks, tags, client, loadInitial]); + + const writeTags = useCallback( + async (mutate: (f: TagsFile) => TagsFile, message: string) => { + const result = await client.update("tags.json", mutate, message); + if (!mounted.current) return; + setTags({ data: result.data, etag: result.etag, sha: result.sha }); + }, + [client], + ); + + useEffect(() => { + mounted.current = true; + void loadInitial(); + return () => { + mounted.current = false; + }; + }, [loadInitial]); + + return { + bookmarksFile: bookmarks?.data ?? null, + tagsFile: tags?.data ?? null, + loading, + error, + refresh, + writeTags, + }; +} diff --git a/packages/web/test/hooks.useGitmarksData.test.ts b/packages/web/test/hooks.useGitmarksData.test.ts new file mode 100644 index 0000000..2272701 --- /dev/null +++ b/packages/web/test/hooks.useGitmarksData.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { useGitmarksData } from "../src/hooks/useGitmarksData.js"; +import type { GitHubClient } from "@gitmarks/core"; + +const bookmarksFile: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [], +}; +const tagsFile: TagsFile = { version: 1, tags: {} }; + +function fakeClient(over: Partial = {}): GitHubClient { + const base: any = { + read: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") return { data: bookmarksFile, sha: "b1", etag: '"b"' }; + if (path === "tags.json") return { data: tagsFile, sha: "t1", etag: '"t"' }; + throw new Error("unexpected path"); + }), + readIfChanged: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }; + return Object.assign(base, over) as GitHubClient; +} + +describe("useGitmarksData", () => { + it("loads both files on mount", async () => { + const client = fakeClient(); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.bookmarksFile).toEqual(bookmarksFile); + expect(result.current.tagsFile).toEqual(tagsFile); + expect(result.current.error).toBeNull(); + }); + + it("refresh() uses readIfChanged with the stored etag and skips on 304", async () => { + const client = fakeClient(); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refresh(); + }); + + expect((client.readIfChanged as any)).toHaveBeenCalledWith("bookmarks.json", '"b"'); + expect((client.readIfChanged as any)).toHaveBeenCalledWith("tags.json", '"t"'); + }); + + it("refresh() applies a fresh result when ETag changes", async () => { + const updated: BookmarksFile = { ...bookmarksFile, updated_at: "2026-05-24T00:00:00Z" }; + const client = fakeClient({ + readIfChanged: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") return { data: updated, sha: "b2", etag: '"b2"' }; + return null; + }), + } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.bookmarksFile).toEqual(updated); + }); + + it("writeTags() calls client.update on tags.json with the mutator", async () => { + const update = vi.fn().mockResolvedValue({ data: tagsFile, sha: "t2", etag: '"t2"' }); + const client = fakeClient({ update } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const mutator = (f: TagsFile) => f; + await act(async () => { + await result.current.writeTags(mutator, "test commit"); + }); + + expect(update).toHaveBeenCalledWith("tags.json", mutator, "test commit"); + }); + + it("sets error when initial read throws", async () => { + const client = fakeClient({ + read: vi.fn().mockRejectedValue(new Error("boom")), + } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toMatch(/boom/); + expect(result.current.bookmarksFile).toBeNull(); + }); +}); From d4804e5fc0f088899626f6d37ba6897b5b547b02 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:31:17 -0400 Subject: [PATCH 12/20] feat(web): pure search + visibility helpers --- packages/web/src/lib/data.ts | 22 +++++++++ packages/web/test/lib.data.test.ts | 78 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/web/src/lib/data.ts create mode 100644 packages/web/test/lib.data.test.ts diff --git a/packages/web/src/lib/data.ts b/packages/web/src/lib/data.ts new file mode 100644 index 0000000..c7a5933 --- /dev/null +++ b/packages/web/src/lib/data.ts @@ -0,0 +1,22 @@ +import type { Bookmark, BookmarksFile } from "@gitmarks/core"; + +export function visibleBookmarks(file: BookmarksFile): Bookmark[] { + return file.bookmarks.filter((b) => b.deleted_at == null); +} + +export function searchBookmarks(bookmarks: Bookmark[], query: string): Bookmark[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return bookmarks; + return bookmarks.filter((b) => { + if (b.title.toLowerCase().includes(q)) return true; + if (b.url.toLowerCase().includes(q)) return true; + if (b.notes != null && b.notes.toLowerCase().includes(q)) return true; + return b.tags.some((t) => t.toLowerCase().includes(q)); + }); +} + +export function allUsedTags(bookmarks: Bookmark[]): Set { + const out = new Set(); + for (const b of bookmarks) for (const t of b.tags) out.add(t); + return out; +} diff --git a/packages/web/test/lib.data.test.ts b/packages/web/test/lib.data.test.ts new file mode 100644 index 0000000..7d2cb5e --- /dev/null +++ b/packages/web/test/lib.data.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import type { Bookmark, BookmarksFile } from "@gitmarks/core"; +import { searchBookmarks, visibleBookmarks, allUsedTags } from "../src/lib/data.js"; + +function mk(over: Partial = {}): Bookmark { + return { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0C1", + url: "https://example.com/article", + title: "Article", + folder: "", + tags: [], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: null, + notes: null, + ...over, + }; +} + +const file: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", title: "Hacker News", url: "https://news.ycombinator.com/", tags: ["daily"] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", title: "Lobsters", url: "https://lobste.rs/", tags: ["daily"] }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", title: "Tailwind Docs", url: "https://tailwindcss.com/docs", tags: ["reference"], notes: "color tokens here" }), + mk({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CD", title: "Tombstone", url: "https://gone.example.com/", deleted_at: "2026-05-10T00:00:00Z" }), + ], +}; + +describe("visibleBookmarks", () => { + it("filters out tombstoned bookmarks", () => { + expect(visibleBookmarks(file)).toHaveLength(3); + expect(visibleBookmarks(file).map((b) => b.id)).not.toContain("01HXYZ8K7M9P3RQ2V5W6Z8B0CD"); + }); +}); + +describe("searchBookmarks", () => { + it("returns all visible bookmarks for an empty query", () => { + expect(searchBookmarks(visibleBookmarks(file), "")).toHaveLength(3); + }); + + it("matches title case-insensitively", () => { + expect(searchBookmarks(visibleBookmarks(file), "tailwind")).toHaveLength(1); + expect(searchBookmarks(visibleBookmarks(file), "TAILWIND")).toHaveLength(1); + }); + + it("matches URL substring", () => { + expect(searchBookmarks(visibleBookmarks(file), "lobste.rs")).toHaveLength(1); + }); + + it("matches tags", () => { + expect(searchBookmarks(visibleBookmarks(file), "daily")).toHaveLength(2); + }); + + it("matches notes", () => { + expect(searchBookmarks(visibleBookmarks(file), "color tokens")).toHaveLength(1); + }); + + it("returns empty array for no matches", () => { + expect(searchBookmarks(visibleBookmarks(file), "unrelated-xyz")).toHaveLength(0); + }); + + it("trims whitespace from the query", () => { + expect(searchBookmarks(visibleBookmarks(file), " tailwind ")).toHaveLength(1); + }); +}); + +describe("allUsedTags", () => { + it("returns the set of tag names referenced by visible bookmarks", () => { + expect(allUsedTags(visibleBookmarks(file))).toEqual(new Set(["daily", "reference"])); + }); + + it("returns an empty set when no bookmarks have tags", () => { + expect(allUsedTags([])).toEqual(new Set()); + }); +}); From 7baf764394273e5fb7a10c0cc5d7ee72cc9a98f4 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:34:09 -0400 Subject: [PATCH 13/20] feat(web): bookmark list rendering with tag chips Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/BookmarkList.tsx | 26 +++++++ packages/web/src/components/BookmarkRow.tsx | 37 ++++++++++ packages/web/src/components/TagChip.tsx | 21 ++++++ .../web/test/components.BookmarkList.test.tsx | 67 +++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 packages/web/src/components/BookmarkList.tsx create mode 100644 packages/web/src/components/BookmarkRow.tsx create mode 100644 packages/web/src/components/TagChip.tsx create mode 100644 packages/web/test/components.BookmarkList.test.tsx diff --git a/packages/web/src/components/BookmarkList.tsx b/packages/web/src/components/BookmarkList.tsx new file mode 100644 index 0000000..c59443d --- /dev/null +++ b/packages/web/src/components/BookmarkList.tsx @@ -0,0 +1,26 @@ +import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { BookmarkRow } from "./BookmarkRow.js"; +import { visibleBookmarks } from "../lib/data.js"; + +interface Props { + bookmarksFile: BookmarksFile; + tagsFile: TagsFile; +} + +export function BookmarkList({ bookmarksFile, tagsFile }: Props) { + const items = visibleBookmarks(bookmarksFile); + if (items.length === 0) { + return ( +

    + No bookmarks yet. Save one from a browser extension to see it here. +

    + ); + } + return ( +
      + {items.map((b) => ( + + ))} +
    + ); +} diff --git a/packages/web/src/components/BookmarkRow.tsx b/packages/web/src/components/BookmarkRow.tsx new file mode 100644 index 0000000..17b644d --- /dev/null +++ b/packages/web/src/components/BookmarkRow.tsx @@ -0,0 +1,37 @@ +import type { Bookmark, TagsFile } from "@gitmarks/core"; +import { TagChip } from "./TagChip.js"; + +interface Props { + bookmark: Bookmark; + tagsFile: TagsFile; +} + +export function BookmarkRow({ bookmark, tagsFile }: Props) { + const folder = bookmark.folder.length > 0 ? bookmark.folder : "(root)"; + return ( +
  • +
    + + {bookmark.title} + + {folder} +
    +
    {bookmark.url}
    + {bookmark.tags.length > 0 && ( +
    + {bookmark.tags.map((t) => ( + + ))} +
    + )} + {bookmark.notes != null && ( +

    {bookmark.notes}

    + )} +
  • + ); +} diff --git a/packages/web/src/components/TagChip.tsx b/packages/web/src/components/TagChip.tsx new file mode 100644 index 0000000..406c5fb --- /dev/null +++ b/packages/web/src/components/TagChip.tsx @@ -0,0 +1,21 @@ +import type { TagsFile } from "@gitmarks/core"; + +interface Props { + name: string; + tagsFile: TagsFile; +} + +const DEFAULT_COLOR = "#475569"; + +export function TagChip({ name, tagsFile }: Props) { + const tag = tagsFile.tags[name]; + const color = tag?.color ?? DEFAULT_COLOR; + return ( + + {name} + + ); +} diff --git a/packages/web/test/components.BookmarkList.test.tsx b/packages/web/test/components.BookmarkList.test.tsx new file mode 100644 index 0000000..7961d1b --- /dev/null +++ b/packages/web/test/components.BookmarkList.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { BookmarkList } from "../src/components/BookmarkList.js"; + +const bookmarks: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", + url: "https://news.ycombinator.com/", + title: "Hacker News", + folder: "", + tags: ["daily"], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: null, + notes: null, + }, + { + id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", + url: "https://gone.example.com/", + title: "Deleted", + folder: "", + tags: [], + added_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-10T00:00:00Z", + added_from: "chrome@minerva", + deleted_at: "2026-05-10T00:00:00Z", + notes: null, + }, + ], +}; + +const tags: TagsFile = { + version: 1, + tags: { daily: { color: "#00FFFF", description: null } }, +}; + +describe("BookmarkList", () => { + it("renders one row per non-deleted bookmark", () => { + render(); + expect(screen.getByText("Hacker News")).toBeInTheDocument(); + expect(screen.queryByText("Deleted")).not.toBeInTheDocument(); + }); + + it("renders the URL as an external link", () => { + render(); + const link = screen.getByRole("link", { name: /hacker news/i }); + expect(link).toHaveAttribute("href", "https://news.ycombinator.com/"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders a tag chip per tag", () => { + render(); + expect(screen.getByText("daily")).toBeInTheDocument(); + }); + + it("renders an empty state when there are no visible bookmarks", () => { + const empty: BookmarksFile = { version: 1, updated_at: "now", bookmarks: [] }; + render(); + expect(screen.getByText(/no bookmarks yet/i)).toBeInTheDocument(); + }); +}); From cf8fe2440dd97439a625da642d97fe6f60585210 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:38:22 -0400 Subject: [PATCH 14/20] feat(web): list page with live search and tag filter sidebar Wires up ListPage with SearchBar and TagFilter components; adds memoized client in RequireSettings for stable useEffect deps; routing test updated to use inline placeholder elements. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/App.tsx | 40 ++++++++-- packages/web/src/components/SearchBar.tsx | 20 +++++ packages/web/src/components/TagFilter.tsx | 39 +++++++++ packages/web/src/routes/ListPage.tsx | 79 ++++++++++++++++++- packages/web/src/routes/TagsPage.tsx | 8 +- packages/web/test/App.routing.test.tsx | 6 +- .../web/test/ListPage.integration.test.tsx | 79 +++++++++++++++++++ 7 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 packages/web/src/components/SearchBar.tsx create mode 100644 packages/web/src/components/TagFilter.tsx create mode 100644 packages/web/test/ListPage.integration.test.tsx diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 47ffbe3..4f8cbb0 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -3,20 +3,50 @@ import { Navigate, Outlet, RouterProvider, + useOutletContext, } from "react-router-dom"; import { useMemo } from "react"; -import { loadSettings } from "./lib/settings.js"; +import { loadSettings, type Settings } from "./lib/settings.js"; +import { makeClient } from "./lib/client.js"; +import type { GitHubClient } from "@gitmarks/core"; import { SetupPage } from "./routes/SetupPage.js"; import { ListPage } from "./routes/ListPage.js"; import { TagsPage } from "./routes/TagsPage.js"; +interface AppContext { + settings: Settings; + client: GitHubClient; +} + // Exported for tests, which compose under a MemoryRouter to // sidestep a Node 24 / undici / jsdom AbortSignal incompatibility that breaks // createHashRouter under test. Production wiring is in App() below. export function RequireSettings() { const settings = loadSettings(); - if (settings == null) return ; - return ; + // useMemo keyed on settings fields so client identity is stable across renders. + // useGitmarksData's effect deps include the client; an unstable client re-fires the load. + const client = useMemo( + () => (settings != null ? makeClient(settings) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [settings?.token, settings?.owner, settings?.repo, settings?.branch], + ); + if (settings == null || client == null) return ; + const ctx: AppContext = { settings, client }; + return ; +} + +export function useAppContext(): AppContext { + return useOutletContext(); +} + +function ListPageWithContext() { + const { client } = useAppContext(); + return ; +} + +function TagsPageWithContext() { + const { client } = useAppContext(); + return ; } export function App() { @@ -27,8 +57,8 @@ export function App() { { element: , children: [ - { path: "/", element: }, - { path: "/tags", element: }, + { path: "/", element: }, + { path: "/tags", element: }, ], }, ]), diff --git a/packages/web/src/components/SearchBar.tsx b/packages/web/src/components/SearchBar.tsx new file mode 100644 index 0000000..9175f1c --- /dev/null +++ b/packages/web/src/components/SearchBar.tsx @@ -0,0 +1,20 @@ +interface Props { + value: string; + onChange: (next: string) => void; +} + +export function SearchBar({ value, onChange }: Props) { + return ( + + ); +} diff --git a/packages/web/src/components/TagFilter.tsx b/packages/web/src/components/TagFilter.tsx new file mode 100644 index 0000000..ca39200 --- /dev/null +++ b/packages/web/src/components/TagFilter.tsx @@ -0,0 +1,39 @@ +import type { TagsFile } from "@gitmarks/core"; + +interface Props { + used: Set; + tagsFile: TagsFile; + selected: string | null; + onSelect: (name: string | null) => void; +} + +const DEFAULT_COLOR = "#475569"; + +export function TagFilter({ used, tagsFile, selected, onSelect }: Props) { + const names = [...used].sort(); + if (names.length === 0) { + return

    no tags in use

    ; + } + return ( +
      + {names.map((name) => { + const color = tagsFile.tags[name]?.color ?? DEFAULT_COLOR; + const isSelected = selected === name; + return ( +
    • + +
    • + ); + })} +
    + ); +} diff --git a/packages/web/src/routes/ListPage.tsx b/packages/web/src/routes/ListPage.tsx index 4deb13a..3100924 100644 --- a/packages/web/src/routes/ListPage.tsx +++ b/packages/web/src/routes/ListPage.tsx @@ -1,7 +1,78 @@ -export function ListPage() { +import { useMemo, useState } from "react"; +import type { GitHubClient } from "@gitmarks/core"; +import { useGitmarksData } from "../hooks/useGitmarksData.js"; +import { BookmarkList } from "../components/BookmarkList.js"; +import { SearchBar } from "../components/SearchBar.js"; +import { TagFilter } from "../components/TagFilter.js"; +import { Layout, type LayoutStatus } from "../components/Layout.js"; +import { allUsedTags, searchBookmarks, visibleBookmarks } from "../lib/data.js"; + +interface Props { + client: GitHubClient; +} + +export function ListPage({ client }: Props) { + const { bookmarksFile, tagsFile, loading, error, refresh } = useGitmarksData(client); + const [query, setQuery] = useState(""); + const [selectedTag, setSelectedTag] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const visible = useMemo( + () => (bookmarksFile != null ? visibleBookmarks(bookmarksFile) : []), + [bookmarksFile], + ); + const tagFiltered = useMemo( + () => (selectedTag == null ? visible : visible.filter((b) => b.tags.includes(selectedTag))), + [visible, selectedTag], + ); + const searched = useMemo( + () => searchBookmarks(tagFiltered, query), + [tagFiltered, query], + ); + const used = useMemo(() => allUsedTags(visible), [visible]); + + const status: LayoutStatus = loading + ? { kind: "loading", message: "loading…" } + : error != null + ? { kind: "err", message: error } + : { kind: "ok", message: `${visible.length} bookmarks` }; + + const filteredFile = bookmarksFile != null + ? { ...bookmarksFile, bookmarks: searched } + : null; + + async function onRefresh() { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + } + return ( -
    -

    Bookmarks

    -
    + +
    + +
    + + {filteredFile != null && tagsFile != null && ( +
    + +
    + )} +
    +
    +
    ); } diff --git a/packages/web/src/routes/TagsPage.tsx b/packages/web/src/routes/TagsPage.tsx index b827265..137aa51 100644 --- a/packages/web/src/routes/TagsPage.tsx +++ b/packages/web/src/routes/TagsPage.tsx @@ -1,4 +1,10 @@ -export function TagsPage() { +import type { GitHubClient } from "@gitmarks/core"; + +interface Props { + client: GitHubClient; +} + +export function TagsPage(_props: Props) { return (

    Tags

    diff --git a/packages/web/test/App.routing.test.tsx b/packages/web/test/App.routing.test.tsx index ccb2a6a..3d26cc3 100644 --- a/packages/web/test/App.routing.test.tsx +++ b/packages/web/test/App.routing.test.tsx @@ -3,8 +3,6 @@ import { render, screen } from "@testing-library/react"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { RequireSettings } from "../src/App.js"; import { SetupPage } from "../src/routes/SetupPage.js"; -import { ListPage } from "../src/routes/ListPage.js"; -import { TagsPage } from "../src/routes/TagsPage.js"; import { saveSettings } from "../src/lib/settings.js"; function AppRoutes({ initialPath = "/" }: { initialPath?: string }) { @@ -13,8 +11,8 @@ function AppRoutes({ initialPath = "/" }: { initialPath?: string }) { } /> }> - } /> - } /> + list} /> + tags} /> diff --git a/packages/web/test/ListPage.integration.test.tsx b/packages/web/test/ListPage.integration.test.tsx new file mode 100644 index 0000000..4d6be27 --- /dev/null +++ b/packages/web/test/ListPage.integration.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import type { BookmarksFile, GitHubClient, TagsFile } from "@gitmarks/core"; +import { ListPage } from "../src/routes/ListPage.js"; + +const bookmarksFile: BookmarksFile = { + version: 1, + updated_at: "2026-05-23T00:00:00Z", + bookmarks: [ + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", url: "https://news.ycombinator.com/", title: "Hacker News", folder: "", tags: ["daily"], added_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", added_from: "chrome@minerva", deleted_at: null, notes: null }, + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", url: "https://lobste.rs/", title: "Lobsters", folder: "", tags: ["daily"], added_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", added_from: "chrome@minerva", deleted_at: null, notes: null }, + { id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", url: "https://tailwindcss.com/docs", title: "Tailwind", folder: "", tags: ["reference"], added_at: "2026-05-01T00:00:00Z", updated_at: "2026-05-01T00:00:00Z", added_from: "chrome@minerva", deleted_at: null, notes: null }, + ], +}; + +const tagsFile: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: null }, + reference: { color: "#00FF88", description: null }, + }, +}; + +function fakeClient(): GitHubClient { + return { + read: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") return { data: bookmarksFile, sha: "b", etag: '"b"' }; + if (path === "tags.json") return { data: tagsFile, sha: "t", etag: '"t"' }; + throw new Error("unexpected"); + }), + readIfChanged: vi.fn().mockResolvedValue(null), + update: vi.fn(), + } as any; +} + +describe("ListPage integration", () => { + it("filters the list when the user types in the search box", async () => { + const user = userEvent.setup(); + render( + + + , + ); + expect(await screen.findByText("Hacker News")).toBeInTheDocument(); + await user.type(screen.getByLabelText(/search/i), "tailwind"); + expect(screen.getByText("Tailwind")).toBeInTheDocument(); + expect(screen.queryByText("Hacker News")).not.toBeInTheDocument(); + }); + + it("filters the list when a tag chip is clicked in the sidebar", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await screen.findByText("Hacker News"); + await user.click(screen.getByRole("button", { name: /^daily$/i })); + expect(screen.getByText("Hacker News")).toBeInTheDocument(); + expect(screen.getByText("Lobsters")).toBeInTheDocument(); + expect(screen.queryByText("Tailwind")).not.toBeInTheDocument(); + }); + + it("clears the tag filter when the same chip is clicked again", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await screen.findByText("Hacker News"); + const chip = screen.getByRole("button", { name: /^daily$/i }); + await user.click(chip); + await user.click(chip); + expect(screen.getByText("Tailwind")).toBeInTheDocument(); + }); +}); From 0a1a0f9b5cef8ea698eea38f6694eb87400f34f0 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:41:21 -0400 Subject: [PATCH 15/20] feat(web): pure tag mutation helpers Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/lib/tag-mutations.ts | 58 +++++++++++++ packages/web/test/lib.tag-mutations.test.ts | 93 +++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 packages/web/src/lib/tag-mutations.ts create mode 100644 packages/web/test/lib.tag-mutations.test.ts diff --git a/packages/web/src/lib/tag-mutations.ts b/packages/web/src/lib/tag-mutations.ts new file mode 100644 index 0000000..8be271d --- /dev/null +++ b/packages/web/src/lib/tag-mutations.ts @@ -0,0 +1,58 @@ +import type { TagsFile } from "@gitmarks/core"; + +const COLOR_RE = /^#[0-9A-Fa-f]{6}$/; + +function assertColor(color: string): void { + if (!COLOR_RE.test(color)) { + throw new Error(`invalid color (expected #RRGGBB, got "${color}")`); + } +} + +function assertName(name: string): void { + if (name.length === 0) throw new Error("tag name must not be empty"); +} + +export function addTag( + file: TagsFile, + name: string, + color: string, + description: string | null, +): TagsFile { + assertName(name); + assertColor(color); + if (file.tags[name] !== undefined) { + throw new Error(`tag "${name}" already exists`); + } + return { ...file, tags: { ...file.tags, [name]: { color, description } } }; +} + +export function setTagColor(file: TagsFile, name: string, color: string): TagsFile { + assertColor(color); + const existing = file.tags[name]; + if (existing === undefined) throw new Error(`tag "${name}" not found`); + return { + ...file, + tags: { ...file.tags, [name]: { ...existing, color } }, + }; +} + +export function renameTag(file: TagsFile, oldName: string, newName: string): TagsFile { + if (oldName === newName) return file; + assertName(newName); + const existing = file.tags[oldName]; + if (existing === undefined) throw new Error(`tag "${oldName}" not found`); + if (file.tags[newName] !== undefined) { + throw new Error(`tag "${newName}" already exists`); + } + const next = { ...file.tags }; + delete next[oldName]; + next[newName] = existing; + return { ...file, tags: next }; +} + +export function deleteTag(file: TagsFile, name: string): TagsFile { + if (file.tags[name] === undefined) throw new Error(`tag "${name}" not found`); + const next = { ...file.tags }; + delete next[name]; + return { ...file, tags: next }; +} diff --git a/packages/web/test/lib.tag-mutations.test.ts b/packages/web/test/lib.tag-mutations.test.ts new file mode 100644 index 0000000..b0f11fa --- /dev/null +++ b/packages/web/test/lib.tag-mutations.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import type { TagsFile } from "@gitmarks/core"; +import { addTag, deleteTag, renameTag, setTagColor } from "../src/lib/tag-mutations.js"; + +const file: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: "open every morning" }, + "to-read": { color: "#FFFF00", description: null }, + }, +}; + +describe("addTag", () => { + it("adds a new tag", () => { + const next = addTag(file, "reference", "#00FF88", "docs and refs"); + expect(next.tags["reference"]).toEqual({ color: "#00FF88", description: "docs and refs" }); + }); + + it("does not mutate the input", () => { + addTag(file, "reference", "#00FF88", null); + expect(file.tags["reference"]).toBeUndefined(); + }); + + it("throws when adding a tag that already exists", () => { + expect(() => addTag(file, "daily", "#FF0000", null)).toThrow(/already exists/); + }); + + it("rejects invalid color format", () => { + expect(() => addTag(file, "x", "red", null)).toThrow(/color/i); + }); + + it("rejects empty name", () => { + expect(() => addTag(file, "", "#FFFFFF", null)).toThrow(/name/i); + }); +}); + +describe("setTagColor", () => { + it("updates the color of an existing tag", () => { + const next = setTagColor(file, "daily", "#123456"); + expect(next.tags["daily"]?.color).toBe("#123456"); + expect(next.tags["daily"]?.description).toBe("open every morning"); + }); + + it("throws when the tag doesn't exist", () => { + expect(() => setTagColor(file, "missing", "#FFFFFF")).toThrow(/not found/); + }); + + it("rejects invalid color format", () => { + expect(() => setTagColor(file, "daily", "purple")).toThrow(/color/i); + }); +}); + +describe("renameTag", () => { + it("renames a tag entry", () => { + const next = renameTag(file, "to-read", "queue"); + expect(next.tags["queue"]).toEqual(file.tags["to-read"]); + expect(next.tags["to-read"]).toBeUndefined(); + }); + + it("does not mutate the input", () => { + renameTag(file, "to-read", "queue"); + expect(file.tags["to-read"]).toBeDefined(); + }); + + it("throws when source doesn't exist", () => { + expect(() => renameTag(file, "missing", "x")).toThrow(/not found/); + }); + + it("throws when destination already exists", () => { + expect(() => renameTag(file, "daily", "to-read")).toThrow(/already exists/); + }); + + it("no-ops when old and new names are identical", () => { + expect(renameTag(file, "daily", "daily")).toEqual(file); + }); +}); + +describe("deleteTag", () => { + it("removes a tag entry", () => { + const next = deleteTag(file, "daily"); + expect(next.tags["daily"]).toBeUndefined(); + expect(next.tags["to-read"]).toBeDefined(); + }); + + it("throws when the tag doesn't exist", () => { + expect(() => deleteTag(file, "missing")).toThrow(/not found/); + }); + + it("does not mutate the input", () => { + deleteTag(file, "daily"); + expect(file.tags["daily"]).toBeDefined(); + }); +}); From 89b0b997cd87a9501972eaefb5d8756c96f22406 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:47:46 -0400 Subject: [PATCH 16/20] feat(web): tag manager UI writing to tags.json Adds TagManager component with inline rename/color/delete per tag and a new-tag row; validation errors surface inline without calling onMutate. Wires TagsPage to use TagManager via useGitmarksData.writeTags. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/TagManager.tsx | 129 ++++++++++++++++++ packages/web/src/routes/TagsPage.tsx | 48 ++++++- .../web/test/components.TagManager.test.tsx | 80 +++++++++++ 3 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/components/TagManager.tsx create mode 100644 packages/web/test/components.TagManager.test.tsx diff --git a/packages/web/src/components/TagManager.tsx b/packages/web/src/components/TagManager.tsx new file mode 100644 index 0000000..0c58c1d --- /dev/null +++ b/packages/web/src/components/TagManager.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from "react"; +import type { TagsFile } from "@gitmarks/core"; +import { addTag, deleteTag, renameTag, setTagColor } from "../lib/tag-mutations.js"; + +type Mutator = (file: TagsFile) => TagsFile; + +interface Props { + tagsFile: TagsFile; + onMutate: (mutator: Mutator) => Promise; +} + +export function TagManager({ tagsFile, onMutate }: Props) { + const [error, setError] = useState(null); + const [newName, setNewName] = useState(""); + + async function safeMutate(mutator: Mutator): Promise { + setError(null); + try { + mutator(tagsFile); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return; + } + await onMutate(mutator); + } + + return ( +
    +

    Tags

    +

    + Renaming a tag updates tags.json only; existing bookmarks still reference the old name. +

    + {error &&

    {error}

    } + +
      + {Object.entries(tagsFile.tags).map(([name, tag]) => ( + safeMutate((f) => renameTag(f, name, next))} + onColor={(next) => safeMutate((f) => setTagColor(f, name, next))} + onDelete={() => safeMutate((f) => deleteTag(f, name))} + /> + ))} +
    + +
    + + +
    +
    + ); +} + +interface RowProps { + name: string; + color: string; + onRename: (next: string) => Promise; + onColor: (next: string) => Promise; + onDelete: () => Promise; +} + +function TagRow({ name, color, onRename, onColor, onDelete }: RowProps) { + const [draft, setDraft] = useState(name); + const colorRef = useRef(null); + const onColorRef = useRef(onColor); + onColorRef.current = onColor; + + // Attach a native change listener so that direct `.value` assignment + dispatchEvent + // works in tests without relying on React's value-change tracking (which skips onChange + // when `.value` is set programmatically before dispatch). + useEffect(() => { + const el = colorRef.current; + if (!el) return; + function handleChange() { + void onColorRef.current(el!.value); + } + el.addEventListener("change", handleChange); + return () => { el.removeEventListener("change", handleChange); }; + }, []); + + return ( +
  • + + setDraft(e.target.value)} + onBlur={() => { if (draft !== name) void onRename(draft); }} + className="flex-1 px-3 py-2 bg-mist border border-fog rounded text-cyan-soft focus:border-cyan focus:outline-none" + /> + +
  • + ); +} diff --git a/packages/web/src/routes/TagsPage.tsx b/packages/web/src/routes/TagsPage.tsx index 137aa51..45a2f34 100644 --- a/packages/web/src/routes/TagsPage.tsx +++ b/packages/web/src/routes/TagsPage.tsx @@ -1,13 +1,51 @@ -import type { GitHubClient } from "@gitmarks/core"; +import { useState } from "react"; +import type { GitHubClient, TagsFile } from "@gitmarks/core"; +import { TagManager } from "../components/TagManager.js"; +import { Layout, type LayoutStatus } from "../components/Layout.js"; +import { useGitmarksData } from "../hooks/useGitmarksData.js"; interface Props { client: GitHubClient; } -export function TagsPage(_props: Props) { +export function TagsPage({ client }: Props) { + const { tagsFile, loading, error, refresh, writeTags } = useGitmarksData(client); + const [refreshing, setRefreshing] = useState(false); + const [writeError, setWriteError] = useState(null); + + const status: LayoutStatus = loading + ? { kind: "loading", message: "loading…" } + : writeError != null + ? { kind: "err", message: writeError } + : error != null + ? { kind: "err", message: error } + : tagsFile != null + ? { kind: "ok", message: `${Object.keys(tagsFile.tags).length} tags` } + : { kind: "loading", message: "loading…" }; + + async function onRefresh() { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + } + + async function onMutate(mutator: (f: TagsFile) => TagsFile) { + setWriteError(null); + try { + await writeTags(mutator, "web: update tags"); + } catch (err) { + setWriteError(err instanceof Error ? err.message : String(err)); + } + } + return ( -
    -

    Tags

    -
    + +
    + {tagsFile != null && } +
    +
    ); } diff --git a/packages/web/test/components.TagManager.test.tsx b/packages/web/test/components.TagManager.test.tsx new file mode 100644 index 0000000..219ba3f --- /dev/null +++ b/packages/web/test/components.TagManager.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { TagsFile } from "@gitmarks/core"; +import { TagManager } from "../src/components/TagManager.js"; + +const tagsFile: TagsFile = { + version: 1, + tags: { + daily: { color: "#00FFFF", description: "open every morning" }, + "to-read": { color: "#FFFF00", description: null }, + }, +}; + +describe("TagManager", () => { + it("lists existing tags", () => { + render(); + expect(screen.getByDisplayValue("daily")).toBeInTheDocument(); + expect(screen.getByDisplayValue("to-read")).toBeInTheDocument(); + }); + + it("calls onMutate with a renaming mutator when the name input is committed", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + const input = screen.getByDisplayValue("daily"); + await user.clear(input); + await user.type(input, "morning"); + await user.tab(); + expect(onMutate).toHaveBeenCalledOnce(); + const mutator = onMutate.mock.calls[0]![0]; + const next = mutator(tagsFile); + expect(next.tags["morning"]).toBeDefined(); + expect(next.tags["daily"]).toBeUndefined(); + }); + + it("calls onMutate with a color mutator when the color input changes", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + const colorInput = screen.getByLabelText(/color for daily/i) as HTMLInputElement; + await user.click(colorInput); + colorInput.value = "#123456"; + colorInput.dispatchEvent(new Event("change", { bubbles: true })); + expect(onMutate).toHaveBeenCalled(); + const mutator = onMutate.mock.calls[0]![0]; + expect(mutator(tagsFile).tags["daily"]?.color).toBe("#123456"); + }); + + it("calls onMutate with a delete mutator when the delete button is clicked", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /delete daily/i })); + const mutator = onMutate.mock.calls[0]![0]; + expect(mutator(tagsFile).tags["daily"]).toBeUndefined(); + }); + + it("adds a new tag through the new-tag row", async () => { + const onMutate = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText(/new tag name/i), "reference"); + await user.click(screen.getByRole("button", { name: /add tag/i })); + const mutator = onMutate.mock.calls[0]![0]; + expect(mutator(tagsFile).tags["reference"]).toEqual({ color: "#22d3ee", description: null }); + }); + + it("surfaces a validation error inline without calling onMutate", async () => { + const onMutate = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByDisplayValue("daily"); + await user.clear(input); + await user.type(input, "to-read"); + await user.tab(); + expect(screen.getByText(/already exists/i)).toBeInTheDocument(); + expect(onMutate).not.toHaveBeenCalled(); + }); +}); From fa6c0d772dd4ba42a443520d735da8255811d7ef Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:49:38 -0400 Subject: [PATCH 17/20] refactor(web): use React onChange for color input, fireEvent in tests Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/TagManager.tsx | 23 +++---------------- .../web/test/components.TagManager.test.tsx | 7 ++---- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/web/src/components/TagManager.tsx b/packages/web/src/components/TagManager.tsx index 0c58c1d..9dddb79 100644 --- a/packages/web/src/components/TagManager.tsx +++ b/packages/web/src/components/TagManager.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import type { TagsFile } from "@gitmarks/core"; import { addTag, deleteTag, renameTag, setTagColor } from "../lib/tag-mutations.js"; @@ -83,30 +83,13 @@ interface RowProps { function TagRow({ name, color, onRename, onColor, onDelete }: RowProps) { const [draft, setDraft] = useState(name); - const colorRef = useRef(null); - const onColorRef = useRef(onColor); - onColorRef.current = onColor; - - // Attach a native change listener so that direct `.value` assignment + dispatchEvent - // works in tests without relying on React's value-change tracking (which skips onChange - // when `.value` is set programmatically before dispatch). - useEffect(() => { - const el = colorRef.current; - if (!el) return; - function handleChange() { - void onColorRef.current(el!.value); - } - el.addEventListener("change", handleChange); - return () => { el.removeEventListener("change", handleChange); }; - }, []); - return (
  • { void onColor(e.target.value); }} className="w-8 h-8 bg-transparent border border-fog rounded cursor-pointer" /> { it("calls onMutate with a color mutator when the color input changes", async () => { const onMutate = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); render(); const colorInput = screen.getByLabelText(/color for daily/i) as HTMLInputElement; - await user.click(colorInput); - colorInput.value = "#123456"; - colorInput.dispatchEvent(new Event("change", { bubbles: true })); + fireEvent.change(colorInput, { target: { value: "#123456" } }); expect(onMutate).toHaveBeenCalled(); const mutator = onMutate.mock.calls[0]![0]; expect(mutator(tagsFile).tags["daily"]?.color).toBe("#123456"); From c4c48598d93cb5f89d5067e6f103e61b6ad6c0f2 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:52:51 -0400 Subject: [PATCH 18/20] docs(web): add package README and update root docs Add packages/web/README.md with dev/build/smoke-test/architecture sections. Mark Web UI v1 complete in root README roadmap, append @gitmarks/web to the packages table, update the architecture diagram. Update CLAUDE.md package list and roadmap to reflect shipped status. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 5 ++- README.md | 5 ++- packages/web/README.md | 87 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 packages/web/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 132de3a..90031a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,9 @@ Two packages are merged to main and working: - `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 96 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. - `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map. - `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on". +- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. Read-side web UI: list, search, tag management. Talks directly to GitHub via `@gitmarks/core`. Routes via hash (`#/setup`, `#/`, `#/tags`). -Pending packages (in dependency order): Firefox build, web UI (read + search + tags), web UI (write + bulk ops), Safari. +Pending packages (in dependency order): web UI (write + bulk ops), Safari. `spec.md` remains the source of truth for design decisions that aren't visible in the code. @@ -101,7 +102,7 @@ pnpm --filter @gitmarks/extension-chrome e2e 2. ✅ Chrome MVP (toolbar save) 3. ✅ Chrome native tree integration 4. ✅ Firefox MV3 add-on (`webextension-polyfill` + extension-shared) — issue [#23](https://github.com/paperhurts/gitmarks/issues/23) -5. ⬜ Web UI v1: list / search / tag management — issue [#24](https://github.com/paperhurts/gitmarks/issues/24) +5. ✅ Web UI v1: list / search / tag management — issue [#24](https://github.com/paperhurts/gitmarks/issues/24) 6. ⬜ Web UI v2: bulk operations + trash + export — issue [#25](https://github.com/paperhurts/gitmarks/issues/25) 7. ⬜ Safari (`safari-web-extension-converter`) — issue [#26](https://github.com/paperhurts/gitmarks/issues/26) diff --git a/README.md b/README.md index 781684b..98303b8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ the roadmap. See `spec.md` for the full design. | `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 96 unit tests live here. | | `@gitmarks/extension-chrome` | Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e. Thin entry files import from `extension-shared`. | | `@gitmarks/extension-firefox` | Firefox MV3 shell. Manifest + plain Vite build. Same source as Chrome via `extension-shared`. Load via `about:debugging`. | +| `@gitmarks/web` | Static SPA — list, search, tag management. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. | ## Quick start (Chrome extension) @@ -91,7 +92,7 @@ The repo is a pnpm workspace monorepo. Each package has its own ## Architecture ``` -[Chrome ext] [Firefox ext] [Safari ext (planned)] [Web UI (planned)] +[Chrome ext] [Firefox ext] [Safari ext (planned)] [Web UI] \ | / / \ | / / v v v v @@ -126,7 +127,7 @@ The load-bearing invariants: - ✅ Chrome native tree integration — listeners, reconcile, poll loop - ✅ Tracking-param stripping (opt-in) - ✅ Firefox MV3 add-on ([#23](https://github.com/paperhurts/gitmarks/issues/23)) -- ⬜ Web UI v1: list + search + tag management ([#24](https://github.com/paperhurts/gitmarks/issues/24)) +- ✅ Web UI v1: list + search + tag management ([#24](https://github.com/paperhurts/gitmarks/issues/24)) - ⬜ Web UI v2: bulk operations + trash + export ([#25](https://github.com/paperhurts/gitmarks/issues/25)) - ⬜ Safari ([#26](https://github.com/paperhurts/gitmarks/issues/26)) diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000..0f0e956 --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,87 @@ +# @gitmarks/web + +Static SPA for browsing and tagging your gitmarks. Vite + React + Tailwind. +Reads `bookmarks.json` and `tags.json` directly from GitHub via the Contents +API; no server. + +## Develop + +```bash +pnpm --filter @gitmarks/web dev +``` + +The dev server runs at `http://localhost:5173/`. Hash routes: + +- `#/setup` — PAT + owner + repo + branch entry, with a Validate step +- `#/` — list page (search + tag filter sidebar) +- `#/tags` — tag manager (rename, recolor, add, delete) + +On first load with no settings stored, the router redirects to `#/setup`. + +## Build + +```bash +pnpm --filter @gitmarks/web build +``` + +The output lands in `packages/web/dist/`. `base: "./"` is set so the build +works under any path — drop the folder onto GitHub Pages or Cloudflare Pages. + +## Manual smoke test + +After running `pnpm --filter @gitmarks/web dev`: + +- [ ] Open `http://localhost:5173/` — the app redirects to `#/setup`. +- [ ] Enter a valid fine-grained PAT (Contents: read/write on your bookmarks + repo), owner, repo, branch. Click **Validate** → green confirmation. +- [ ] Click **Save** → the app redirects to the list view. +- [ ] If the repo has bookmarks, they render with tag chips and folder labels. +- [ ] Type in the search box — the list filters live. +- [ ] Click a tag in the sidebar — only bookmarks with that tag remain. + Click the same tag again to clear the filter. +- [ ] Click **Sync from GitHub** — the status pill briefly says "Syncing…" + then returns to the bookmark count. If you edit `bookmarks.json` + directly on github.com first, the new entry appears after the sync. +- [ ] Open `#/tags`. Rename a tag, change its color, add a new tag, delete + a tag. Each action commits to `tags.json` immediately. Refresh the + page and confirm the changes persisted. + +## Scope (v1) + +Read-side only. Bookmark creation, editing, bulk operations, trash view, and +Netscape HTML export are tracked separately as [#25 Web UI v2](https://github.com/paperhurts/gitmarks/issues/25). + +## Architecture + +``` +src/ + main.tsx # React entry + App.tsx # RouterProvider; settings gate via + index.css # Tailwind directives + lib/ + settings.ts # localStorage wrapper with Zod validation + client.ts # GitHubClient factory + validateConnection + data.ts # pure helpers: visibleBookmarks, searchBookmarks, allUsedTags + tag-mutations.ts # pure helpers: addTag/renameTag/setTagColor/deleteTag + hooks/ + useGitmarksData.ts # loads both files with ETag; refresh + writeTags + components/ + Layout.tsx, SetupForm.tsx, BookmarkList.tsx, BookmarkRow.tsx, + TagChip.tsx, SearchBar.tsx, TagFilter.tsx, TagManager.tsx + routes/ + SetupPage.tsx, ListPage.tsx, TagsPage.tsx +``` + +Page-level components own data + state; the dumb components take props and +emit callbacks. Writes go through `client.update()` from `@gitmarks/core`, +which transparently handles 409 retry-replay. + +## Deploying to GitHub Pages + +```bash +pnpm --filter @gitmarks/web build +# Copy packages/web/dist/ into the gh-pages branch of any repo, or use the +# `gh-pages` npm package to push. +``` + +Because `base: "./"` is set, the build works at any path. From 10646fe9bd39d4db8d45a9eff23109ae43b2607e Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 20:57:10 -0400 Subject: [PATCH 19/20] fix(web): seed empty BookmarksFile on 404 instead of surfacing as error --- packages/web/src/hooks/useGitmarksData.ts | 27 ++++++++++++++++--- .../web/test/hooks.useGitmarksData.test.ts | 16 +++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/web/src/hooks/useGitmarksData.ts b/packages/web/src/hooks/useGitmarksData.ts index e62a25f..e008dea 100644 --- a/packages/web/src/hooks/useGitmarksData.ts +++ b/packages/web/src/hooks/useGitmarksData.ts @@ -1,6 +1,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { GitHubNotFoundError } from "@gitmarks/core"; import type { BookmarksFile, GitHubClient, TagsFile } from "@gitmarks/core"; +const EMPTY_BOOKMARKS: BookmarksFile = { version: 1, updated_at: "", bookmarks: [] }; +const EMPTY_TAGS: TagsFile = { version: 1, tags: {} }; + +async function readOrEmpty( + client: GitHubClient, + path: string, + empty: T, +): Promise<{ data: T; etag: string; sha: string }> { + try { + return await client.read(path); + } catch (err) { + if (err instanceof GitHubNotFoundError) return { data: empty, etag: "", sha: "" }; + throw err; + } +} + interface Loaded { data: T; etag: string; @@ -30,14 +47,16 @@ export function useGitmarksData(client: GitHubClient): UseGitmarksData { setLoading(true); setError(null); try { + // 404 on either file is treated as empty — a freshly-set-up repo may not + // have bookmarks.json yet (extension creates it on first save) or tags.json + // (created on first tag-manager mutation). All other errors propagate. const [b, t] = await Promise.all([ - client.read("bookmarks.json"), - client.read("tags.json").catch(() => null), + readOrEmpty(client, "bookmarks.json", EMPTY_BOOKMARKS), + readOrEmpty(client, "tags.json", EMPTY_TAGS), ]); if (!mounted.current) return; setBookmarks({ data: b.data, etag: b.etag, sha: b.sha }); - if (t != null) setTags({ data: t.data, etag: t.etag, sha: t.sha }); - else setTags({ data: { version: 1, tags: {} }, etag: "", sha: "" }); + setTags({ data: t.data, etag: t.etag, sha: t.sha }); } catch (err) { if (!mounted.current) return; setError(err instanceof Error ? err.message : String(err)); diff --git a/packages/web/test/hooks.useGitmarksData.test.ts b/packages/web/test/hooks.useGitmarksData.test.ts index 2272701..ed69c1e 100644 --- a/packages/web/test/hooks.useGitmarksData.test.ts +++ b/packages/web/test/hooks.useGitmarksData.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import type { BookmarksFile, TagsFile } from "@gitmarks/core"; +import { GitHubNotFoundError } from "@gitmarks/core"; import { useGitmarksData } from "../src/hooks/useGitmarksData.js"; import type { GitHubClient } from "@gitmarks/core"; @@ -88,4 +89,19 @@ describe("useGitmarksData", () => { expect(result.current.error).toMatch(/boom/); expect(result.current.bookmarksFile).toBeNull(); }); + + it("seeds an empty BookmarksFile when bookmarks.json 404s on first load", async () => { + const client = fakeClient({ + read: vi.fn().mockImplementation(async (path: string) => { + if (path === "bookmarks.json") throw new GitHubNotFoundError(path); + if (path === "tags.json") return { data: tagsFile, sha: "t1", etag: '"t"' }; + throw new Error("unexpected path"); + }), + } as any); + const { result } = renderHook(() => useGitmarksData(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeNull(); + expect(result.current.bookmarksFile).toEqual({ version: 1, updated_at: "", bookmarks: [] }); + expect(result.current.tagsFile).toEqual(tagsFile); + }); }); From 5fe9e3bbff900b7f58137890d9c80ced09155dc1 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Mon, 25 May 2026 11:24:48 -0400 Subject: [PATCH 20/20] docs: refresh project status, test counts, and add @gitmarks/web architecture section --- CLAUDE.md | 23 ++++++++++++++++++++--- README.md | 7 ++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 90031a2..42f47bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,14 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project status -Two packages are merged to main and working: +Five packages are merged to main and working: - `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 65 unit tests. - `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 96 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. - `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map. - `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on". -- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. Read-side web UI: list, search, tag management. Talks directly to GitHub via `@gitmarks/core`. Routes via hash (`#/setup`, `#/`, `#/tags`). +- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. Read-side web UI: list, search, tag management. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`). 67 unit + component tests. -Pending packages (in dependency order): web UI (write + bulk ops), Safari. +Total: 228 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell. + +Pending packages (in dependency order): web UI v2 (write + bulk ops), Safari. `spec.md` remains the source of truth for design decisions that aren't visible in the code. @@ -74,6 +76,21 @@ Each is a thin browser-specific shell over `@gitmarks/extension-shared`: - Own HTML files (duplicated across shells because Vite needs them as build inputs — known follow-up) - Chrome owns the Playwright e2e suite; Firefox relies on the manual smoke test in its README +### `@gitmarks/web` (`packages/web/`) + +Vite + React 18 + Tailwind 3 SPA. Read-side: list, search, tag management. Hash routing (`createHashRouter`) so it deploys at any path on GitHub Pages or Cloudflare Pages. Cyan/magenta on dark per `spec.md`. + +- **Settings** (`src/lib/settings.ts`): Zod-validated `localStorage` wrapper for `{token, owner, repo, branch}`. Same PAT model as the extensions. +- **Client wrapper** (`src/lib/client.ts`): `makeClient(settings, fetch?)` builds a `GitHubClient`; `validateConnection` returns a discriminated `ValidateResult` (`ok-with-files` | `ok-no-files` | `auth-failed` | `repo-not-found` | `network-error`). +- **Data hook** (`src/hooks/useGitmarksData.ts`): loads `bookmarks.json` + `tags.json` on mount; tracks ETags and uses `readIfChanged` on `refresh()`. Seeds empty files on 404 so freshly-set-up users see the empty state, not an error. `writeTags(mutator, message)` delegates to `client.update("tags.json", …)` for 409 retry-replay. +- **Pure helpers** (`src/lib/data.ts`, `src/lib/tag-mutations.ts`): `visibleBookmarks` (filters tombstones), `searchBookmarks` (case-insensitive substring across title/url/tags/notes), `allUsedTags`, plus `addTag`/`renameTag`/`setTagColor`/`deleteTag`. All pure so they can be replayed inside `client.update`. +- **Routes** (`src/routes/`): `SetupPage` (PAT entry + Validate + Save), `ListPage` (search + tag-filter sidebar + BookmarkList), `TagsPage` (TagManager wired to `writeTags`). +- **Layout** (`src/components/Layout.tsx`): header, nav, status pill (loading/ok/warn/err), Sync-from-GitHub button. + +**Tag rename is decoupled from bookmark refs by design** — `renameTag` only mutates `tags.json`. Bookmark `tags[]` entries still reference the old name until updated by the extension's save path. Per `spec.md` §"`tags.json`": "Separate file so renaming a tag doesn't churn every bookmark." + +**Test compromise worth knowing:** routing tests use `MemoryRouter + Routes/Route` rather than the production `createHashRouter` to sidestep a Node 24 / undici / jsdom AbortSignal incompatibility (`createHashRouter` triggers `new Request()` whose AbortSignal jsdom doesn't recognize). `RequireSettings` is exported from `App.tsx` for this purpose; the test setup file has a narrow `unhandledRejection` filter for the same root cause. Production wiring uses the real hash router and is verified by the manual smoke test in `packages/web/README.md`. + ## Testing - **Unit tests** (`packages/*/test/*.test.ts`): Vitest. The extension package uses jsdom + a `chrome.*` stub at `test/setup.ts` for tests that touch the chrome API. Pure logic is unit-tested in isolation. diff --git a/README.md b/README.md index 98303b8..b3085a9 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ you control. **Status:** Chrome extension is functional end-to-end (save via toolbar button, two-way sync with the native bookmark tree, 5-min poll for remote changes, automatic conflict retry). Firefox MV3 add-on shipping the same -source as Chrome via a shared package. Safari / web UI are next in -the roadmap. See `spec.md` for the full design. +source as Chrome via a shared package. Web UI v1 (read-side: list, search, +tag management) deploys as a static SPA. Web UI v2 (bulk ops + trash + +export) and Safari are next in the roadmap. See `spec.md` for the full design. ## Features (Chrome, today) @@ -25,7 +26,7 @@ the roadmap. See `spec.md` for the full design. on the next 5-minute poll - Concurrent edits from multiple devices reconcile automatically via GitHub's file SHA + optimistic retry-replay -- 162 automated tests (unit + Playwright e2e against real Chromium) +- 228 automated unit + component tests + 6 Playwright e2e (against real Chromium) - Optional **tracking-param stripping** (utm_*, fbclid, gclid, etc.) at save time — opt-in via settings ## Packages