aboutsummaryrefslogtreecommitdiff
path: root/PLAN.md
diff options
context:
space:
mode:
Diffstat (limited to 'PLAN.md')
-rw-r--r--PLAN.md218
1 files changed, 0 insertions, 218 deletions
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index f97e7ac..0000000
--- a/PLAN.md
+++ /dev/null
@@ -1,218 +0,0 @@
-# PLAN.md — @eol/graphiql
-
-A Svelte 5 GraphiQL alternative. JSR-published under the EOL scope.
-
-## Current state (v0.2 complete)
-
-Shipped:
-
-- HTTP fetcher with injectable `fetch` impl
-- Schema introspection and SDL caching
-- Session store with tabs, persistence, and rename
-- TabBar with double-click inline rename (Enter commits, Escape cancels, blur commits)
-- Toolbar with Cmd/Ctrl+Enter run shortcut
-- Variables + Headers panes (switchable in bottom-left)
-- Debounced localStorage persistence (300ms) with `beforeunload` flush
-- Memory storage fallback for SSR
-- CodeMirror 6 editor wrapper via `cm6-graphql` with dynamic import (SSR-safe)
-- `readOnly` prop on Editor, used by ResultViewer
-- Reactive schema updates via `$effect` calling `updateSchema()`
-- Public API surface in `src/lib/index.ts`
-
-Stub files needing implementation: `src/lib/fetcher/sse.ts`, `src/lib/fetcher/websocket.ts`.
-
-## Coding conventions
-
-Non-negotiable. Match existing files exactly:
-
-- Double quotes, semicolons, two-space indent
-- No trailing spaces, no unnecessary whitespace
-- Svelte component structure: `<script lang="ts">`, then `<style lang="scss">`, then markup
-- Object literal keys alphabetical unless semantic ordering matters
-- TypeScript strict mode, no `any`
-- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`) — no stores, no `writable`
-- No direct `window` or `document` access — use `$app/environment`'s `browser`, or `typeof globalThis.X !== "undefined"` guards
-- No React, no Node-specific APIs (Deno/Bun first)
-- Fix mistakes in place; don't re-scaffold
-
-## Stages
-
-### v0.2.1 — Polish (small, worth doing before v0.3)
-
-**Tab auto-titling.** When typing in a brand-new tab, auto-generate a title from the first meaningful token of the query (operation name if present, else "query"/"mutation"/"subscription", else first 20 chars). Only auto-rename if the user hasn't manually renamed. Track with a `titleDirty: boolean` field on `Tab`. Set it to `true` in `renameTab()` and `renameActive()`.
-
-**Operation detection.** Parse the query with `graphql`'s `parse()` and set `tab.operationName` automatically when there's a single named operation. If multiple operations exist and none selected, surface a picker next to the Run button. Keep parse errors silent — the CM6 linter already shows them.
-
-**Theme prop.** Currently hardcoded to `oneDark`. Extract to an optional `theme?: Extension` prop on the root component, defaulting to `oneDark`. Consumers can pass any CM6 theme extension. A light theme preset (`import("@codemirror/theme")` basics or a small hand-rolled one) should ship alongside so people can toggle without adding a dependency.
-
-### v0.3 — Doc explorer and history
-
-**Doc explorer (`components/DocExplorer.svelte`).** Right-hand collapsible pane. Walks `schema.schema` (the built `GraphQLSchema` object). Breadcrumb navigation: Root → Type → Field → Arg. Clicking a field pushes onto a navigation stack. Clicking a breadcrumb crumb pops to that level.
-
-Structure:
-
-- `DocExplorer.svelte` — container, manages nav stack via `$state`
-- `DocExplorer/TypeView.svelte` — renders a `GraphQLObjectType`, `GraphQLInterfaceType`, `GraphQLUnionType`, `GraphQLEnumType`, `GraphQLInputObjectType`, or `GraphQLScalarType`
-- `DocExplorer/FieldView.svelte` — renders a single field with args and return type
-- `DocExplorer/TypeLink.svelte` — clickable type reference (unwraps `NonNull`/`List`)
-
-Use the `graphql` package's `isObjectType`, `isInterfaceType`, etc. guards — don't duck-type.
-
-Toggle visibility with a toolbar button. Width around 320px. Collapsed state persisted via the same storage adapter under a `"docExplorer"` key.
-
-**History panel (`components/HistoryPanel.svelte`).** Drawer from the left. Logs every successful query execution (query + variables + headers + timestamp + operationName). New history store: `state/history.svelte.ts` with `HistoryStore` class, methods `add(entry)`, `remove(id)`, `clear()`, `favorite(id)`. Cap at 100 entries; favorites never evict. Clicking an entry loads it into the active tab (overwriting). Shift-click opens in a new tab.
-
-Persist under `"history"` key. Same debounce pattern as session.
-
-### v0.4 — Subscriptions (SSE + WebSocket)
-
-Replace the stubs in `fetcher/sse.ts` and `fetcher/websocket.ts`.
-
-**SSE implementation.** Use `graphql-sse` protocol. The fetcher signature already allows `AsyncIterable<FetcherResult>` as a return type — use it. Stream each `next` event as an iteration. Close the `EventSource` on `complete` or consumer abort.
-
-**WebSocket implementation.** Use the `graphql-ws` library (JSR: `npm:graphql-ws`). Return an `AsyncIterable` that yields each `next` message. Handle `connection_init`, `ping`/`pong`, subscription IDs, and clean teardown.
-
-**Root component update.** `GraphiQL.svelte`'s `run()` needs to detect `AsyncIterable` return values and stream them into `tab.result`. Append each payload with a timestamp header, or replace on each — make it a prop choice (`subscriptionMode: "replace" | "append"`, default `"append"`).
-
-**Protocol detection.** Add a prop to pick transport per-operation: if the query contains a subscription, prefer WS; otherwise HTTP. Let consumers override by passing a custom fetcher that dispatches internally.
-
-### v0.5 — Theming and plugin slots
-
-**Theming.** Two layers: the editor theme (a CM6 `Extension` passed as a prop per v0.2.1) and the surrounding chrome theme (CSS custom properties like `--graphiql-bg`, `--graphiql-fg`). Extract the chrome variables into `src/lib/styles/theme.scss` with light/dark mode variables gated by `@media (prefers-color-scheme)` and an override class. Ship at least two CM6 theme presets (dark via `oneDark`, light via a small hand-rolled `EditorView.theme({...})`). Document the full variable list and theme prop API in README.
-
-**Plugin slots.** Use Svelte snippets (the `{@render}` / `Snippet` type from Svelte 5). Add optional props to `GraphiQL.svelte`:
-
-- `toolbarExtras?: Snippet` — rendered after the Run button
-- `resultFooter?: Snippet<[{ result: string }]>` — rendered below the result pane
-- `tabExtras?: Snippet<[{ tab: Tab }]>` — rendered inside each tab button
-
-This keeps the plugin story type-safe without a bespoke registry.
-
-### v0.6 — Ergonomics (keyboard + format)
-
-**Keyboard shortcuts.** Extend the existing `onKeydown` handler in `GraphiQL.svelte` (already bound via `<svelte:window>`). Add, in priority order:
-
-- `Cmd/Ctrl + Shift + Enter` — new tab (`session.addTab()`). We do **not** use `Cmd+T`; browsers intercept it and `preventDefault` is unreliable across hosts. Document this in README — if an embedder is running in Tauri/Electron they can remap to `Cmd+T` themselves.
-- `Cmd/Ctrl + Shift + W` — close active tab (`session.closeTab(session.activeId)`). Same reasoning — `Cmd+W` is browser-owned.
-- `Cmd/Ctrl + Alt + Right/Left` — next/prev tab. Wraps around end-to-start. No-op on a single tab.
-- `Cmd/Ctrl + Shift + F` — format active query (see below).
-
-Extract the handler into `state/keyboard.ts` as a pure function `matchShortcut(event) → Action | null` so `tests/keyboard.test.ts` can cover matrix cases without a DOM.
-
-**Query formatting.** New file `graphql/format.ts` exporting `format(query: string): string` that calls `print(parse(query))` from `graphql`. On parse failure, return the original string unchanged — don't throw. Wire into:
-
-- A `Format` button in `Toolbar.svelte`, placed between the operation picker and `extras`. Disabled when the active tab query is empty or unparseable (check via `operations.length === 0 && query.trim().length > 0`? No — cheaper to attempt `format()` and compare; if equal to input *and* parse would have failed, disable. Actually simplest: always enable, no-op on unparseable).
-- `Cmd/Ctrl + Shift + F` shortcut above. Route through `session.formatActive()` which sets `tab.query = format(tab.query)` and re-derives operations.
-
-Gotcha: `print()` strips comments. Document this — we are not going to keep a CST round-trip for v0.6.
-
-### v0.7 — Session portability
-
-**Export/import session JSON.** Extend `SessionStore` with:
-
-- `exportTab(id: string): TabExport | null` — strips `id`, `result`, `operations` (derivable), keeps `headers`, `operationName`, `query`, `title`, `variables`, and a version tag.
-- `exportAll(): SessionExport` — `{ version: 1, exportedAt: ISO, tabs: TabExport[] }`.
-- `importTabs(data: SessionExport, opts: { mode: "replace" | "append" }): ImportResult` — validates the `version` field and tab shape (lightweight hand-rolled check, no zod); rejects unknown versions with a descriptive error; returns `{ added: number, skipped: number, errors: string[] }`.
-
-Shape validation lives in `state/session-io.ts` so it can be unit tested without the store. Treat imported JSON as untrusted — reject if any string field exceeds 1 MB (covers accidental mega-pastes), cap tab count at 50 per import.
-
-**UI surface.** Two buttons in the History panel header (already has `Clear`) — `Export` and `Import`. Export downloads via `Blob` + object URL, filename `graphiql-session-{ISO}.json`. Import uses a hidden `<input type="file" accept="application/json">` triggered by the button. Show an `ImportResult` toast-style row inline in the panel on completion; no toast library.
-
-### v0.8 — Layout resize
-
-**Splitter component.** `components/Splitter.svelte`, no external deps. Props: `orientation: "horizontal" | "vertical"`, `min: number`, `max: number`, `value: number`, `onChange: (v: number) => void`. Internal: `pointerdown` captures the pointer, `pointermove` computes delta against the parent element's bounding rect, `pointerup` releases capture. Writes to `value` via `onChange` on every move (consumer is expected to throttle via `$effect` if needed — store writes are already debounced).
-
-**Wire into `GraphiQL.svelte`.** Replace the fixed `grid-template-columns` / `grid-template-rows` with CSS custom properties driven by `$state`:
-
-- `--graphiql-left-width` (default `1fr`, resized via middle splitter between query column and result)
-- `--graphiql-bottom-height` (default split between editor and variables/headers pane — currently `2fr auto 1fr`, change the `1fr` to `var(--graphiql-bottom-height, 1fr)`)
-- `--graphiql-docs-width` (default `320px`, only present when docs open)
-- `--graphiql-history-width` (default `260px`, only present when history open)
-
-Persist each to storage under `layout.{key}` keys, hydrated in the constructor. Storage accepts numbers; we re-stringify as `{n}px` or fractions depending on which axis.
-
-Three splitters total: between history/left, between left/right, between right/docs, and a horizontal one inside `.left` between the query editor and the variables/headers pane. Keep total splitter DOM cost at four elements.
-
-### v0.9 — Timing display
-
-**Instrumentation at the run boundary.** Modify `SessionStore.run()` only — don't touch the `Fetcher` signature. Record:
-
-- `tab.timing.startMs = performance.now()` before the fetcher call.
-- `tab.timing.firstByteMs = performance.now()` on first payload (either the awaited object or the first iterator step).
-- `tab.timing.endMs = performance.now()` on completion.
-- For subscriptions: `tab.streamIntervals: number[]` — delta between each successive payload, capped at 500 entries per run.
-
-Add `timing` and `streamIntervals` fields to `Tab`. Render a small row under `ResultViewer` (not a footer snippet — that's for consumers) showing `→ 42ms · first byte 18ms` for one-shot, or `→ 12 messages · median 430ms` for streams. Compute the median inline; no helpers dir.
-
-Subscription streams don't have a single end; treat `endMs` as the time of the last payload and update it on each step so consumers see a rolling duration while streaming.
-
-**Do not** attempt to surface server-side trace extensions (Apollo Tracing, `response.extensions.tracing`) in v0.9 — that's a separate feature with its own UI. Document as future work.
-
-### v0.10 — Persisted queries (APQ)
-
-**Wrapper fetcher.** New file `fetcher/apq.ts`, exported as `createApqFetcher(inner: Fetcher, options?: ApqOptions): Fetcher`. Implements the Apollo Automatic Persisted Queries protocol:
-
-1. Compute `sha256Hash` of the query string with `globalThis.crypto.subtle.digest("SHA-256", ...)`. Cache per-query in a `Map<string, string>` to avoid rehashing.
-2. First attempt — send `{ operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }` without the `query`. Wrap the inner fetcher by passing a synthetic `FetcherRequest` with `query: ""` and stash the hash in `headers["x-apq-hash"]`? No — cleaner: don't reuse `inner`, build the body ourselves. This means APQ needs its own HTTP fetcher variant or needs to extend the inner fetcher contract.
-
- **Decision:** APQ is HTTP-specific. Implement `createApqFetcher(options: FetcherOptions): Fetcher` that builds the body directly, bypassing `createHttpFetcher`. Share the header-merging logic via a small helper `fetcher/http-body.ts`. SSE/WS get no APQ support in v0.10 — document as out of scope.
-
-3. If response contains `{ errors: [{ message: "PersistedQueryNotFound", ... }] }`, retry with `{ query, operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }`.
-4. Cache the hash → query mapping in memory for the fetcher's lifetime; no disk persistence in v0.10 (servers already cache).
-
-`ApqOptions`:
-
-- Everything from `FetcherOptions`
-- `disable?: boolean` — escape hatch for debugging, forces full query on every request.
-
-**Export.** Add `createApqFetcher` and `ApqOptions` to `source/library/index.ts`. No UI changes — this is consumer-facing plumbing.
-
-### v1.0 — Stabilization
-
-Not a feature stage. Before tagging v1.0:
-
-- README audit: every exported name has an example.
-- `deno publish --dry-run` clean, no type errors.
-- Every `*.svelte.ts` store has a companion `tests/*.test.ts`.
-- Theme variables documented in one table in the README.
-- One known-good end-to-end story: run against a real GraphQL endpoint (e.g., the Countries API) via an example app in `examples/` (optional, can skip if the README reproducer is enough).
-
-## Publishing
-
-The project publishes to JSR as `@eol/graphiql`. Current `jsr.json` and `deno.json` are set up for v0.2.0. Bump versions with ChronVer per NetOpWibby's convention. The `exports` points at `src/lib/index.ts`; JSR ships `.svelte` files as-is and the consumer's SvelteKit build compiles them. **Do not pre-compile Svelte components** — that breaks SSR and prevents consumers from using their own Svelte version.
-
-Run `deno publish --dry-run` before tagging.
-
-## Known gotchas
-
-**CM6 extension composition.** CodeMirror 6 is extension-based — every feature is an opt-in `Extension`. The current `Editor.svelte` bundles a reasonable default set (line numbers, history, bracket matching, close brackets, indent-on-input, default + history keymaps, syntax highlighting, oneDark theme). If consumers want a leaner editor (e.g., for the result viewer), the cleanest path is to make the extension array a prop. Not done yet — v0.2.1 `theme` prop is the entry point for this refactor.
-
-**Schema reactivity.** Schema changes are handled by an `$effect` that calls `cm6-graphql`'s `updateSchema(view, newSchema)`. This is the documented CM6 pattern — do not recreate the editor on schema change, as that loses cursor position, history, and undo stack. If `schema` is invalid SDL, the catch block silently skips and the editor keeps working without schema awareness.
-
-**`crypto.randomUUID()`** works in Deno, modern browsers, and Bun. It does not work in Node.js before 14.17. Since we're Deno/Bun first this is fine; flag in docs if someone reports Node issues.
-
-**Nested button in TabBar.** The close `×` button is nested inside the tab `<button>`. Technically invalid HTML but works everywhere. If a11y linters complain, refactor the outer `<button>` to `<div role="tab" tabindex="0">` with explicit keyboard handlers.
-
-**Schema introspection failures.** Some GraphQL servers disable introspection in production. Surface the error clearly (already done in `.status` bar) but make sure queries still run without schema — the CM6 editor falls back to plain GraphQL syntax highlighting when `sdl` is empty.
-
-## Testing
-
-No test harness yet. For v0.3 add Deno's built-in test runner targeting the state stores (pure logic, no DOM):
-
-- `tests/session.test.ts` — tab add/close/rename/persist round-trip
-- `tests/storage.test.ts` — memory storage + namespace isolation
-- `tests/history.test.ts` — eviction and favorite pinning
-
-Component tests can wait until there's demand; Svelte 5 + runes + Vitest is still settling.
-
-## Architecture notes for future me
-
-The separation between **state stores** (`*.svelte.ts` classes) and **components** is deliberate. Stores own all mutation logic and can be unit tested without a DOM. Components own rendering and side effects (persistence timers, keyboard handlers).
-
-The `$effect` in `GraphiQL.svelte` intentionally reads `session.tabs` and `session.activeId` via `void` to establish dependencies without using the values. This is idiomatic runes and cleaner than `$effect.root` with explicit subscriptions.
-
-The fetcher abstraction is intentionally minimal — one function type that returns a Promise or AsyncIterable. This covers HTTP, SSE, WS, and any future transport without an interface explosion. Transport-specific options live in the factory function, not the fetcher itself.
-
-**Why CodeMirror 6 over Monaco.** Bundle size, build simplicity, and philosophical fit. Monaco is multi-megabyte with required web worker setup in Vite — a real friction point for consumers. CM6 is tens of kilobytes, composed of opt-in extensions, and works with zero build config. `cm6-graphql` is maintained inside the graphql/graphiql monorepo itself and uses the same `graphql-language-service` that powers `monaco-graphql`, so we get schema-aware autocomplete and linting without the bloat. The tradeoff is slightly less polished hover docs and no signature-help popups — acceptable for v1, addressable later via custom CM6 extensions if needed.
-
-When in doubt about API shape, look at `@graphiql/toolkit` for reference — the GraphiQL maintainers have already factored out the transport concerns nicely, we're just giving it a Svelte + CM6 skin.