aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 11:33:25 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 11:33:25 -0700
commit8a59f92d031963e23ecc84b75feecf43eb4dd146 (patch)
tree75de5768885583897061a3b1795e4c987ce90039
downloadgraphiql-8a59f92d031963e23ecc84b75feecf43eb4dd146.tar.gz
graphiql-8a59f92d031963e23ecc84b75feecf43eb4dd146.zip
Initial commit: @eol/graphiql v0.3
Svelte 5 GraphiQL alternative for JSR. Covers: - HTTP fetcher with injectable fetch; SSE/WS stubs - Session store with tabs, auto-titling, persistence, rename - Operation detection via graphql parse(); Toolbar picker - CodeMirror 6 editor via cm6-graphql with theme prop - Light theme preset (hand-rolled EditorView.theme) - Doc explorer with breadcrumb nav and type guards - History panel with 100-entry cap, favorite pinning - Deno tests for operations, storage, and history eviction
-rw-r--r--.gitignore4
-rw-r--r--PLAN.md138
-rw-r--r--deno.json29
-rw-r--r--deno.lock204
-rw-r--r--source/library/GraphiQL.svelte322
-rw-r--r--source/library/components/DocExplorer.svelte226
-rw-r--r--source/library/components/DocExplorer/FieldView.svelte87
-rw-r--r--source/library/components/DocExplorer/TypeLink.svelte42
-rw-r--r--source/library/components/DocExplorer/TypeView.svelte199
-rw-r--r--source/library/components/Editor.svelte136
-rw-r--r--source/library/components/HeadersEditor.svelte34
-rw-r--r--source/library/components/HistoryPanel.svelte187
-rw-r--r--source/library/components/ResultViewer.svelte35
-rw-r--r--source/library/components/TabBar.svelte161
-rw-r--r--source/library/components/Toolbar.svelte150
-rw-r--r--source/library/fetcher/http.ts30
-rw-r--r--source/library/fetcher/sse.ts18
-rw-r--r--source/library/fetcher/types.ts20
-rw-r--r--source/library/fetcher/websocket.ts18
-rw-r--r--source/library/graphql/operations.ts56
-rw-r--r--source/library/index.ts28
-rw-r--r--source/library/runes.d.ts37
-rw-r--r--source/library/state/history-logic.ts20
-rw-r--r--source/library/state/history.svelte.ts94
-rw-r--r--source/library/state/schema.svelte.ts41
-rw-r--r--source/library/state/session.svelte.ts242
-rw-r--r--source/library/state/storage.ts77
-rw-r--r--source/library/themes/light.ts75
-rw-r--r--tests/history.test.ts77
-rw-r--r--tests/operations.test.ts64
-rw-r--r--tests/storage.test.ts83
31 files changed, 2934 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3b26ea2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.DS_Store
+Thumbs.db
+
+.claude
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..25f806e
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,138 @@
+# 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+ — Nice to have
+
+- Export/import session state as JSON (for sharing)
+- Query formatting button (uses `graphql`'s `print()`)
+- Keyboard shortcut for new tab (Cmd+T), close tab (Cmd+W), next/prev tab (Cmd+Opt+Right/Left)
+- Split-pane resize handles (use CSS `resize: horizontal` or a lightweight splitter)
+- Request/response timing display
+- Persisted query support (APQ)
+
+## 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.
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..61efce7
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "lib": ["deno.window", "dom", "dom.iterable"],
+ "strict": true
+ },
+ "exports": "./source/library/index.ts",
+ "imports": {
+ "@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.18.0",
+ "@codemirror/commands": "npm:@codemirror/commands@^6.7.0",
+ "@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1",
+ "@codemirror/language": "npm:@codemirror/language@^6.10.0",
+ "@codemirror/state": "npm:@codemirror/state@^6.4.0",
+ "@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.0",
+ "@codemirror/view": "npm:@codemirror/view@^6.34.0",
+ "@lezer/highlight": "npm:@lezer/highlight@^1.2.0",
+ "cm6-graphql": "npm:cm6-graphql@^0.2.1",
+ "codemirror": "npm:codemirror@^6.0.1",
+ "graphql": "npm:graphql@^16.8.0"
+ },
+ "name": "@eol/graphiql",
+ "publish": {
+ "include": ["source/library/**/*", "README.md", "LICENSE"]
+ },
+ "tasks": {
+ "check": "deno check source/library/**/*.ts",
+ "test": "deno test --allow-env --location=http://localhost/ tests/"
+ },
+ "version": "0.1.0"
+}
diff --git a/deno.lock b/deno.lock
new file mode 100644
index 0000000..8774cf1
--- /dev/null
+++ b/deno.lock
@@ -0,0 +1,204 @@
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@std/assert@1": "1.0.19",
+ "jsr:@std/internal@^1.0.12": "1.0.13",
+ "npm:@codemirror/autocomplete@^6.18.0": "6.20.1",
+ "npm:@codemirror/commands@^6.7.0": "6.10.3",
+ "npm:@codemirror/lang-json@^6.0.1": "6.0.2",
+ "npm:@codemirror/language@^6.10.0": "6.12.3",
+ "npm:@codemirror/state@^6.4.0": "6.6.0",
+ "npm:@codemirror/theme-one-dark@^6.1.0": "6.1.3",
+ "npm:@codemirror/view@^6.34.0": "6.41.1",
+ "npm:@lezer/highlight@^1.2.0": "1.2.3",
+ "npm:cm6-graphql@~0.2.1": "0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2",
+ "npm:codemirror@^6.0.1": "6.0.2",
+ "npm:graphql@^16.8.0": "16.13.2"
+ },
+ "jsr": {
+ "@std/assert@1.0.19": {
+ "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
+ "dependencies": [
+ "jsr:@std/internal"
+ ]
+ },
+ "@std/internal@1.0.13": {
+ "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0"
+ }
+ },
+ "npm": {
+ "@codemirror/autocomplete@6.20.1": {
+ "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
+ "dependencies": [
+ "@codemirror/language",
+ "@codemirror/state",
+ "@codemirror/view",
+ "@lezer/common"
+ ]
+ },
+ "@codemirror/commands@6.10.3": {
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "dependencies": [
+ "@codemirror/language",
+ "@codemirror/state",
+ "@codemirror/view",
+ "@lezer/common"
+ ]
+ },
+ "@codemirror/lang-json@6.0.2": {
+ "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
+ "dependencies": [
+ "@codemirror/language",
+ "@lezer/json"
+ ]
+ },
+ "@codemirror/language@6.12.3": {
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "dependencies": [
+ "@codemirror/state",
+ "@codemirror/view",
+ "@lezer/common",
+ "@lezer/highlight",
+ "@lezer/lr",
+ "style-mod"
+ ]
+ },
+ "@codemirror/lint@6.9.5": {
+ "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
+ "dependencies": [
+ "@codemirror/state",
+ "@codemirror/view",
+ "crelt"
+ ]
+ },
+ "@codemirror/search@6.7.0": {
+ "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
+ "dependencies": [
+ "@codemirror/state",
+ "@codemirror/view",
+ "crelt"
+ ]
+ },
+ "@codemirror/state@6.6.0": {
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "dependencies": [
+ "@marijn/find-cluster-break"
+ ]
+ },
+ "@codemirror/theme-one-dark@6.1.3": {
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "dependencies": [
+ "@codemirror/language",
+ "@codemirror/state",
+ "@codemirror/view",
+ "@lezer/highlight"
+ ]
+ },
+ "@codemirror/view@6.41.1": {
+ "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
+ "dependencies": [
+ "@codemirror/state",
+ "crelt",
+ "style-mod",
+ "w3c-keyname"
+ ]
+ },
+ "@lezer/common@1.5.2": {
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="
+ },
+ "@lezer/highlight@1.2.3": {
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "dependencies": [
+ "@lezer/common"
+ ]
+ },
+ "@lezer/json@1.0.3": {
+ "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
+ "dependencies": [
+ "@lezer/common",
+ "@lezer/highlight",
+ "@lezer/lr"
+ ]
+ },
+ "@lezer/lr@1.4.10": {
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "dependencies": [
+ "@lezer/common"
+ ]
+ },
+ "@marijn/find-cluster-break@1.0.2": {
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
+ },
+ "cm6-graphql@0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2": {
+ "integrity": "sha512-FIAFHn6qyiXChTz3Pml0NgTM8LyyXs8QfP2iPG7MLA8Xi83WuVlkGG5PDs+DDeEVabHkLIZmcyNngQlxLXKk6A==",
+ "dependencies": [
+ "@codemirror/autocomplete",
+ "@codemirror/language",
+ "@codemirror/lint",
+ "@codemirror/state",
+ "@codemirror/view",
+ "@lezer/highlight",
+ "graphql",
+ "graphql-language-service"
+ ]
+ },
+ "codemirror@6.0.2": {
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "dependencies": [
+ "@codemirror/autocomplete",
+ "@codemirror/commands",
+ "@codemirror/language",
+ "@codemirror/lint",
+ "@codemirror/search",
+ "@codemirror/state",
+ "@codemirror/view"
+ ]
+ },
+ "crelt@1.0.6": {
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
+ },
+ "debounce-promise@3.1.2": {
+ "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg=="
+ },
+ "graphql-language-service@5.5.0_graphql@16.13.2": {
+ "integrity": "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q==",
+ "dependencies": [
+ "debounce-promise",
+ "graphql",
+ "nullthrows",
+ "vscode-languageserver-types"
+ ],
+ "bin": true
+ },
+ "graphql@16.13.2": {
+ "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="
+ },
+ "nullthrows@1.1.1": {
+ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="
+ },
+ "style-mod@4.1.3": {
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
+ },
+ "vscode-languageserver-types@3.17.5": {
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="
+ },
+ "w3c-keyname@2.2.8": {
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
+ }
+ },
+ "workspace": {
+ "dependencies": [
+ "npm:@codemirror/autocomplete@^6.18.0",
+ "npm:@codemirror/commands@^6.7.0",
+ "npm:@codemirror/lang-json@^6.0.1",
+ "npm:@codemirror/language@^6.10.0",
+ "npm:@codemirror/state@^6.4.0",
+ "npm:@codemirror/theme-one-dark@^6.1.0",
+ "npm:@codemirror/view@^6.34.0",
+ "npm:@lezer/highlight@^1.2.0",
+ "npm:cm6-graphql@~0.2.1",
+ "npm:codemirror@^6.0.1",
+ "npm:graphql@^16.8.0"
+ ]
+ }
+}
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte
new file mode 100644
index 0000000..0a8f01a
--- /dev/null
+++ b/source/library/GraphiQL.svelte
@@ -0,0 +1,322 @@
+<script lang="ts">
+ /*** IMPORT ------------------------------------------- ***/
+
+ import { onMount } from "svelte";
+
+ /*** UTILITY ------------------------------------------ ***/
+
+ import DocExplorer from "./components/DocExplorer.svelte";
+ import Editor from "./components/Editor.svelte";
+ import HeadersEditor from "./components/HeadersEditor.svelte";
+ import HistoryPanel from "./components/HistoryPanel.svelte";
+ import ResultViewer from "./components/ResultViewer.svelte";
+ import TabBar from "./components/TabBar.svelte";
+ import Toolbar from "./components/Toolbar.svelte";
+ import type { Extension } from "@codemirror/state";
+ import type { Fetcher } from "./fetcher/types.ts";
+ import { HistoryStore } from "./state/history.svelte.ts";
+ import { SchemaStore } from "./state/schema.svelte.ts";
+ import { SessionStore } from "./state/session.svelte.ts";
+ import { createLocalStorage, createMemoryStorage } from "./state/storage.ts";
+ import type { Storage } from "./state/storage.ts";
+
+ type Props = {
+ fetcher: Fetcher;
+ initialQuery?: string;
+ namespace?: string;
+ storage?: Storage;
+ theme?: Extension;
+ };
+
+ let {
+ fetcher,
+ initialQuery = "",
+ namespace = "eol-graphiql",
+ storage,
+ theme
+ }: Props = $props();
+
+ const resolvedStorage = storage ??
+ (typeof globalThis.localStorage !== "undefined" ?
+ createLocalStorage(namespace) :
+ createMemoryStorage());
+
+ const PERSIST_DEBOUNCE_MS = 300;
+ const history = new HistoryStore(resolvedStorage);
+ const schema = new SchemaStore();
+ const session = new SessionStore(resolvedStorage);
+
+ if (initialQuery && session.active && session.active.query === "")
+ session.updateQuery(session.active.id, initialQuery);
+
+ let bottomPane = $state<"variables" | "headers">("variables");
+ let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false);
+ let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false);
+ let running = $state(false);
+
+ $effect(() => {
+ void session.tabs;
+ void session.activeId;
+
+ const timer = setTimeout(() => {
+ session.persist();
+ }, PERSIST_DEBOUNCE_MS);
+
+ return () => clearTimeout(timer);
+ });
+
+ $effect(() => {
+ void history.entries;
+
+ const timer = setTimeout(() => {
+ history.persist();
+ }, PERSIST_DEBOUNCE_MS);
+
+ return () => clearTimeout(timer);
+ });
+
+ $effect(() => {
+ resolvedStorage.set("docExplorer", docsOpen);
+ });
+
+ $effect(() => {
+ resolvedStorage.set("historyPanel", historyOpen);
+ });
+
+ onMount(() => {
+ schema.introspect(fetcher);
+ });
+
+ async function run() {
+ if (running)
+ return;
+
+ running = true;
+
+ try {
+ const tab = session.active;
+ const ok = await session.run(fetcher);
+
+ if (ok && tab) {
+ history.add({
+ headers: tab.headers,
+ operationName: tab.operationName,
+ query: tab.query,
+ title: tab.title,
+ variables: tab.variables
+ });
+ }
+ } finally {
+ running = false;
+ }
+ }
+
+ function loadHistory(id: string, inNewTab: boolean) {
+ const entry = history.entries.find((e) => e.id === id);
+
+ if (!entry)
+ return;
+
+ const seed = {
+ headers: entry.headers,
+ operationName: entry.operationName,
+ query: entry.query,
+ title: entry.title,
+ variables: entry.variables
+ };
+
+ if (inNewTab)
+ session.addTab(seed);
+ else
+ session.overwriteActive(seed);
+ }
+
+ function onQueryChange(value: string) {
+ if (session.active)
+ session.updateQuery(session.active.id, value);
+ }
+
+ function onVariablesChange(value: string) {
+ if (session.active)
+ session.active.variables = value;
+ }
+
+ function onHeadersChange(value: string) {
+ if (session.active)
+ session.active.headers = value;
+ }
+
+ function onKeydown(event: KeyboardEvent) {
+ const meta = event.metaKey || event.ctrlKey;
+
+ if (meta && event.key === "Enter") {
+ event.preventDefault();
+ run();
+ }
+ }
+
+ function onBeforeUnload() {
+ session.persist();
+ }
+</script>
+
+<style lang="scss">
+ .graphiql {
+ width: 100%; height: 100%;
+
+ background-color: var(--graphiql-bg, #1e1e1e);
+ color: var(--graphiql-fg, #d4d4d4);
+ display: grid;
+ font-family: var(--graphiql-font, ui-monospace, SFMono-Regular, monospace);
+ grid-template-rows: auto auto 1fr;
+ }
+
+ .panes {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ min-height: 0;
+
+ &.history-open {
+ grid-template-columns: 260px 1fr 1fr;
+ }
+
+ &.docs-open {
+ grid-template-columns: 1fr 1fr 320px;
+ }
+
+ &.history-open.docs-open {
+ grid-template-columns: 260px 1fr 1fr 320px;
+ }
+ }
+
+ .left {
+ border-right: 1px solid var(--graphiql-border, #333);
+ display: grid;
+ grid-template-rows: 2fr auto 1fr;
+ min-height: 0;
+ }
+
+ .query {
+ min-height: 0;
+ }
+
+ .switcher {
+ background-color: var(--graphiql-panel, #252526);
+ border-top: 1px solid var(--graphiql-border, #333);
+ display: flex;
+ }
+
+ .switch {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ padding: 0.375rem 0.75rem;
+ text-transform: uppercase;
+
+ &:not(.active) {
+ color: var(--graphiql-muted, #858585);
+ }
+
+ &.active {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+
+ .bottom {
+ min-height: 0;
+ }
+
+ .right {
+ min-height: 0;
+ }
+
+ .status {
+ background-color: var(--graphiql-panel, #252526);
+ border-top: 1px solid var(--graphiql-border, #333);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ }
+</style>
+
+<svelte:window onbeforeunload={onBeforeUnload} onkeydown={onKeydown}/>
+
+<div class="graphiql">
+ <Toolbar
+ disabled={running || !session.active}
+ docsAvailable={schema.schema !== null}
+ {docsOpen}
+ {historyOpen}
+ onRun={run}
+ onSelectOperation={(name) => {
+ if (session.active)
+ session.selectOperation(session.active.id, name);
+ }}
+ onToggleDocs={() => (docsOpen = !docsOpen)}
+ onToggleHistory={() => (historyOpen = !historyOpen)}
+ operationName={session.active?.operationName ?? null}
+ operations={session.active?.operations ?? []}
+ {running}
+ schemaLoading={schema.loading}/>
+ <TabBar
+ activeId={session.activeId}
+ onAdd={() => session.addTab()}
+ onClose={(id) => session.closeTab(id)}
+ onRename={(id, title) => session.renameTab(id, title)}
+ onSelect={(id) => session.selectTab(id)}
+ tabs={session.tabs}/>
+ <div class="panes" class:docs-open={docsOpen && schema.schema} class:history-open={historyOpen}>
+ {#if historyOpen}
+ <HistoryPanel
+ entries={history.entries}
+ onClear={() => history.clear()}
+ onFavorite={(id) => history.favorite(id)}
+ onLoad={loadHistory}
+ onRemove={(id) => history.remove(id)}/>
+ {/if}
+ <div class="left">
+ <div class="query">
+ <Editor
+ language="graphql"
+ onChange={onQueryChange}
+ schema={schema.sdl}
+ {theme}
+ value={session.active?.query ?? ""}/>
+ </div>
+ <div class="switcher">
+ <button
+ class="switch"
+ class:active={bottomPane === "variables"}
+ onclick={() => (bottomPane = "variables")}>Variables</button>
+ <button
+ class="switch"
+ class:active={bottomPane === "headers"}
+ onclick={() => (bottomPane = "headers")}>Headers</button>
+ </div>
+ <div class="bottom">
+ {#if bottomPane === "variables"}
+ <Editor
+ language="json"
+ onChange={onVariablesChange}
+ {theme}
+ value={session.active?.variables ?? "{}"}/>
+ {:else}
+ <HeadersEditor
+ onChange={onHeadersChange}
+ {theme}
+ value={session.active?.headers ?? "{}"}/>
+ {/if}
+ </div>
+ </div>
+ <div class="right">
+ <ResultViewer {theme} value={session.active?.result ?? ""}/>
+ </div>
+ {#if docsOpen && schema.schema}
+ <DocExplorer schema={schema.schema}/>
+ {/if}
+ </div>
+ {#if schema.error}
+ <div class="status">Schema error: {schema.error}</div>
+ {/if}
+</div>
diff --git a/source/library/components/DocExplorer.svelte b/source/library/components/DocExplorer.svelte
new file mode 100644
index 0000000..536cb2a
--- /dev/null
+++ b/source/library/components/DocExplorer.svelte
@@ -0,0 +1,226 @@
+<script lang="ts">
+ import FieldView from "./DocExplorer/FieldView.svelte";
+ import TypeView from "./DocExplorer/TypeView.svelte";
+ import {
+ isInputObjectType,
+ isInterfaceType,
+ isObjectType
+ } from "graphql";
+ import type {
+ GraphQLField,
+ GraphQLInputField,
+ GraphQLNamedType,
+ GraphQLSchema
+ } from "graphql";
+
+ type NavEntry =
+ | { kind: "field"; fieldName: string; typeName: string }
+ | { kind: "type"; name: string };
+
+ type Props = {
+ schema: GraphQLSchema;
+ };
+
+ let { schema }: Props = $props();
+
+ let stack = $state<NavEntry[]>([]);
+
+ const current = $derived<NavEntry | null>(stack.length > 0 ? stack[stack.length - 1] : null);
+
+ const rootTypes = $derived.by(() => {
+ const out: { label: string; type: GraphQLNamedType }[] = [];
+ const q = schema.getQueryType();
+ const m = schema.getMutationType();
+ const s = schema.getSubscriptionType();
+
+ if (q)
+ out.push({ label: "Query", type: q });
+
+ if (m)
+ out.push({ label: "Mutation", type: m });
+
+ if (s)
+ out.push({ label: "Subscription", type: s });
+
+ return out;
+ });
+
+ const currentType = $derived.by<GraphQLNamedType | null>(() => {
+ if (!current)
+ return null;
+
+ const name = current.kind === "type" ? current.name : current.typeName;
+ return schema.getType(name) ?? null;
+ });
+
+ const currentField = $derived.by<GraphQLField<unknown, unknown> | GraphQLInputField | null>(() => {
+ if (!current || current.kind !== "field" || !currentType)
+ return null;
+
+ if (isObjectType(currentType) || isInterfaceType(currentType) || isInputObjectType(currentType))
+ return currentType.getFields()[current.fieldName] ?? null;
+
+ return null;
+ });
+
+ function crumbLabel(entry: NavEntry): string {
+ return entry.kind === "type" ? entry.name : entry.fieldName;
+ }
+
+ function gotoRoot() {
+ stack = [];
+ }
+
+ function gotoIndex(index: number) {
+ stack = stack.slice(0, index + 1);
+ }
+
+ function pushType(name: string) {
+ if (!schema.getType(name))
+ return;
+
+ stack = [...stack, { kind: "type", name }];
+ }
+
+ function pushField(fieldName: string) {
+ const typeName = current?.kind === "type" ? current.name : null;
+
+ if (!typeName)
+ return;
+
+ stack = [...stack, { fieldName, kind: "field", typeName }];
+ }
+</script>
+
+<style lang="scss">
+ .explorer {
+ background: var(--graphiql-panel, #252526);
+ border-left: 1px solid var(--graphiql-border, #333);
+ display: grid;
+ grid-template-rows: auto 1fr;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .breadcrumbs {
+ align-items: center;
+ border-bottom: 1px solid var(--graphiql-border, #333);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.8125rem;
+ gap: 0.25rem;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .crumb {
+ background: none;
+ border: none;
+ color: var(--graphiql-link, #79b8ff);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.current {
+ color: var(--graphiql-fg, #d4d4d4);
+ cursor: default;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .separator {
+ color: var(--graphiql-muted, #858585);
+ }
+
+ .body {
+ min-height: 0;
+ overflow-y: auto;
+ }
+
+ .root {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ }
+
+ .section-label {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ }
+
+ .root-list {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ .root-link {
+ background: none;
+ border: none;
+ color: var(--graphiql-link, #79b8ff);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.875rem;
+ padding: 0;
+ text-align: left;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .empty {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ padding: 0.75rem 1rem;
+ }
+</style>
+
+<div class="explorer">
+ <div class="breadcrumbs">
+ <button
+ class="crumb"
+ class:current={stack.length === 0}
+ onclick={gotoRoot}>Docs</button>
+ {#each stack as entry, i}
+ <span class="separator">/</span>
+ <button
+ class="crumb"
+ class:current={i === stack.length - 1}
+ onclick={() => gotoIndex(i)}>{crumbLabel(entry)}</button>
+ {/each}
+ </div>
+ <div class="body">
+ {#if stack.length === 0}
+ <div class="root">
+ <div class="section-label">Root Types</div>
+ <div class="root-list">
+ {#each rootTypes as entry}
+ <button class="root-link" onclick={() => pushType(entry.type.name)}>
+ {entry.label}: {entry.type.name}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {:else if currentField}
+ <FieldView field={currentField} onNavigate={pushType}/>
+ {:else if currentType}
+ <TypeView
+ onNavigateField={pushField}
+ onNavigateType={pushType}
+ type={currentType}/>
+ {:else}
+ <div class="empty">Type not found in schema.</div>
+ {/if}
+ </div>
+</div>
diff --git a/source/library/components/DocExplorer/FieldView.svelte b/source/library/components/DocExplorer/FieldView.svelte
new file mode 100644
index 0000000..71d215c
--- /dev/null
+++ b/source/library/components/DocExplorer/FieldView.svelte
@@ -0,0 +1,87 @@
+<script lang="ts">
+ import TypeLink from "./TypeLink.svelte";
+ import type { GraphQLField, GraphQLInputField } from "graphql";
+
+ type Props = {
+ field: GraphQLField<unknown, unknown> | GraphQLInputField;
+ onNavigate: (typeName: string) => void;
+ };
+
+ let { field, onNavigate }: Props = $props();
+
+ const args = $derived("args" in field ? field.args : []);
+</script>
+
+<style lang="scss">
+ .field {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ }
+
+ .heading {
+ font-size: 0.95rem;
+ font-weight: 600;
+ }
+
+ .section-label {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ }
+
+ .description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ }
+
+ .args {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ .arg {
+ font-size: 0.8125rem;
+ }
+
+ .arg-name {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+
+ .arg-description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.75rem;
+ margin-left: 1rem;
+ margin-top: 0.125rem;
+ }
+</style>
+
+<div class="field">
+ <div class="heading">{field.name}</div>
+ {#if field.description}
+ <div class="description">{field.description}</div>
+ {/if}
+ <div>
+ <div class="section-label">Type</div>
+ <TypeLink {onNavigate} type={field.type}/>
+ </div>
+ {#if args.length > 0}
+ <div>
+ <div class="section-label">Arguments</div>
+ <div class="args">
+ {#each args as arg}
+ <div class="arg">
+ <span class="arg-name">{arg.name}</span>:
+ <TypeLink {onNavigate} type={arg.type}/>
+ {#if arg.description}
+ <div class="arg-description">{arg.description}</div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/source/library/components/DocExplorer/TypeLink.svelte b/source/library/components/DocExplorer/TypeLink.svelte
new file mode 100644
index 0000000..253d16e
--- /dev/null
+++ b/source/library/components/DocExplorer/TypeLink.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { getNamedType, isListType, isNonNullType } from "graphql";
+ import type { GraphQLType } from "graphql";
+
+ type Props = {
+ onNavigate: (typeName: string) => void;
+ type: GraphQLType;
+ };
+
+ let { onNavigate, type }: Props = $props();
+
+ function label(t: GraphQLType): string {
+ if (isNonNullType(t))
+ return `${label(t.ofType)}!`;
+
+ if (isListType(t))
+ return `[${label(t.ofType)}]`;
+
+ return t.name;
+ }
+
+ const named = $derived(getNamedType(type));
+ const text = $derived(label(type));
+</script>
+
+<style lang="scss">
+ .link {
+ background: none;
+ border: none;
+ color: var(--graphiql-link, #79b8ff);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+</style>
+
+<button class="link" onclick={() => onNavigate(named.name)}>{text}</button>
diff --git a/source/library/components/DocExplorer/TypeView.svelte b/source/library/components/DocExplorer/TypeView.svelte
new file mode 100644
index 0000000..31a1ca3
--- /dev/null
+++ b/source/library/components/DocExplorer/TypeView.svelte
@@ -0,0 +1,199 @@
+<script lang="ts">
+ import TypeLink from "./TypeLink.svelte";
+ import {
+ isEnumType,
+ isInputObjectType,
+ isInterfaceType,
+ isObjectType,
+ isScalarType,
+ isUnionType
+ } from "graphql";
+ import type { GraphQLNamedType } from "graphql";
+
+ type Props = {
+ onNavigateField: (fieldName: string) => void;
+ onNavigateType: (typeName: string) => void;
+ type: GraphQLNamedType;
+ };
+
+ let { onNavigateField, onNavigateType, type }: Props = $props();
+
+ const fields = $derived.by(() => {
+ if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))
+ return Object.values(type.getFields());
+
+ return [];
+ });
+
+ const interfaces = $derived.by(() => {
+ if (isObjectType(type) || isInterfaceType(type))
+ return type.getInterfaces();
+
+ return [];
+ });
+
+ const unionMembers = $derived.by(() => {
+ if (isUnionType(type))
+ return type.getTypes();
+
+ return [];
+ });
+
+ const enumValues = $derived.by(() => {
+ if (isEnumType(type))
+ return type.getValues();
+
+ return [];
+ });
+
+ const kindLabel = $derived.by(() => {
+ if (isObjectType(type))
+ return "type";
+
+ if (isInterfaceType(type))
+ return "interface";
+
+ if (isUnionType(type))
+ return "union";
+
+ if (isEnumType(type))
+ return "enum";
+
+ if (isInputObjectType(type))
+ return "input";
+
+ if (isScalarType(type))
+ return "scalar";
+
+ return "";
+ });
+</script>
+
+<style lang="scss">
+ .type {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ }
+
+ .heading {
+ font-size: 0.95rem;
+ font-weight: 600;
+ }
+
+ .kind {
+ color: var(--graphiql-muted, #858585);
+ font-weight: normal;
+ margin-right: 0.375rem;
+ }
+
+ .description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ }
+
+ .section-label {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ }
+
+ .list {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ .entry {
+ font-size: 0.8125rem;
+ }
+
+ .field-button {
+ background: none;
+ border: none;
+ color: var(--graphiql-fg, #d4d4d4);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .entry-description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.75rem;
+ margin-left: 1rem;
+ margin-top: 0.125rem;
+ }
+</style>
+
+<div class="type">
+ <div class="heading">
+ {#if kindLabel}<span class="kind">{kindLabel}</span>{/if}{type.name}
+ </div>
+ {#if type.description}
+ <div class="description">{type.description}</div>
+ {/if}
+ {#if interfaces.length > 0}
+ <div>
+ <div class="section-label">Implements</div>
+ <div class="list">
+ {#each interfaces as iface}
+ <div class="entry">
+ <TypeLink onNavigate={onNavigateType} type={iface}/>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {#if fields.length > 0}
+ <div>
+ <div class="section-label">Fields</div>
+ <div class="list">
+ {#each fields as field}
+ <div class="entry">
+ <button
+ class="field-button"
+ onclick={() => onNavigateField(field.name)}>{field.name}</button>:
+ <TypeLink onNavigate={onNavigateType} type={field.type}/>
+ {#if field.description}
+ <div class="entry-description">{field.description}</div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {#if unionMembers.length > 0}
+ <div>
+ <div class="section-label">Members</div>
+ <div class="list">
+ {#each unionMembers as member}
+ <div class="entry">
+ <TypeLink onNavigate={onNavigateType} type={member}/>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {#if enumValues.length > 0}
+ <div>
+ <div class="section-label">Values</div>
+ <div class="list">
+ {#each enumValues as value}
+ <div class="entry">
+ <span>{value.name}</span>
+ {#if value.description}
+ <div class="entry-description">{value.description}</div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/source/library/components/Editor.svelte b/source/library/components/Editor.svelte
new file mode 100644
index 0000000..f2bf82d
--- /dev/null
+++ b/source/library/components/Editor.svelte
@@ -0,0 +1,136 @@
+<script lang="ts">
+ /*** IMPORT ------------------------------------------- ***/
+
+ import { browser } from "$app/environment";
+ import { onMount } from "svelte";
+ import type { Extension } from "@codemirror/state";
+ import type { EditorView } from "@codemirror/view";
+ import type { GraphQLSchema } from "graphql";
+
+ /*** UTILITY ------------------------------------------ ***/
+
+ type Props = {
+ language?: "graphql" | "json";
+ onChange: (value: string) => void;
+ readOnly?: boolean;
+ schema?: string;
+ theme?: Extension;
+ value: string;
+ };
+
+ let {
+ language = "graphql",
+ onChange,
+ readOnly = false,
+ schema,
+ theme,
+ value
+ }: Props = $props();
+
+ let buildSchemaFn = $state<((sdl: string) => GraphQLSchema) | null>(null);
+ let container: HTMLDivElement;
+ let updateSchemaFn = $state<((v: EditorView, s: GraphQLSchema) => void) | null>(null);
+ let view = $state<EditorView | null>(null);
+
+ onMount(() => {
+ if (!browser)
+ return;
+
+ let disposed = false;
+
+ (async () => {
+ const [
+ { EditorView: EV, keymap, lineNumbers, highlightActiveLine },
+ { EditorState },
+ { defaultKeymap, history, historyKeymap },
+ { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput },
+ { closeBrackets, closeBracketsKeymap },
+ { graphql, updateSchema },
+ { json },
+ { buildSchema }
+ ] = await Promise.all([
+ import("@codemirror/view"),
+ import("@codemirror/state"),
+ import("@codemirror/commands"),
+ import("@codemirror/language"),
+ import("@codemirror/autocomplete"),
+ import("cm6-graphql"),
+ import("@codemirror/lang-json"),
+ import("graphql")
+ ]);
+
+ if (disposed)
+ return;
+
+ const themeExt: Extension = theme ?? (await import("@codemirror/theme-one-dark")).oneDark;
+
+ const languageExt = language === "graphql" ?
+ graphql(schema ? buildSchema(schema) : undefined) :
+ json();
+
+ const instance = new EV({
+ parent: container,
+ state: EditorState.create({
+ doc: value,
+ extensions: [
+ lineNumbers(),
+ highlightActiveLine(),
+ history(),
+ bracketMatching(),
+ closeBrackets(),
+ indentOnInput(),
+ syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
+ keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]),
+ languageExt,
+ themeExt,
+ EV.editable.of(!readOnly),
+ EV.updateListener.of((u) => {
+ if (u.docChanged)
+ onChange(u.state.doc.toString());
+ })
+ ]
+ })
+ });
+
+ view = instance;
+ updateSchemaFn = updateSchema;
+ buildSchemaFn = buildSchema;
+ })();
+
+ return () => {
+ disposed = true;
+ view?.destroy();
+ };
+ });
+
+ $effect(() => {
+ if (!view || !updateSchemaFn || !buildSchemaFn)
+ return;
+
+ if (language !== "graphql" || !schema)
+ return;
+
+ try {
+ updateSchemaFn(view, buildSchemaFn(schema));
+ } catch {
+ // Invalid SDL — silently skip; editor keeps working without schema awareness
+ }
+ });
+</script>
+
+<style lang="scss">
+ .editor {
+ height: 100%;
+ width: 100%;
+
+ :global(.cm-editor) {
+ height: 100%;
+ }
+
+ :global(.cm-scroller) {
+ font-family: inherit;
+ }
+ }
+</style>
+
+<div bind:this={container} class="editor"></div>
diff --git a/source/library/components/HeadersEditor.svelte b/source/library/components/HeadersEditor.svelte
new file mode 100644
index 0000000..fc3a193
--- /dev/null
+++ b/source/library/components/HeadersEditor.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import Editor from "./Editor.svelte";
+ import type { Extension } from "@codemirror/state";
+
+ type Props = {
+ onChange: (value: string) => void;
+ theme?: Extension;
+ value: string;
+ };
+
+ let { onChange, theme, value }: Props = $props();
+</script>
+
+<style lang="scss">
+ .headers {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ height: 100%;
+ min-height: 0;
+ }
+
+ .label {
+ background-color: var(--graphiql-panel, #252526);
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ padding: 0.25rem 0.75rem;
+ text-transform: uppercase;
+ }
+</style>
+
+<div class="headers">
+ <div class="label">Headers</div>
+ <Editor language="json" {onChange} {theme} {value}/>
+</div>
diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte
new file mode 100644
index 0000000..b7f5c4c
--- /dev/null
+++ b/source/library/components/HistoryPanel.svelte
@@ -0,0 +1,187 @@
+<script lang="ts">
+ import type { HistoryEntry } from "../state/history.svelte.ts";
+
+ type Props = {
+ entries: HistoryEntry[];
+ onClear: () => void;
+ onFavorite: (id: string) => void;
+ onLoad: (id: string, inNewTab: boolean) => void;
+ onRemove: (id: string) => void;
+ };
+
+ let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props();
+
+ const sorted = $derived([...entries].sort((a, b) => {
+ if (a.favorite !== b.favorite)
+ return a.favorite ? -1 : 1;
+
+ return b.timestamp - a.timestamp;
+ }));
+
+ function formatTimestamp(ms: number): string {
+ const d = new Date(ms);
+ return d.toLocaleString();
+ }
+
+ function onEntryClick(event: MouseEvent, id: string) {
+ onLoad(id, event.shiftKey);
+ }
+
+ function onEntryKey(event: KeyboardEvent, id: string) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onLoad(id, event.shiftKey);
+ }
+ }
+</script>
+
+<style lang="scss">
+ .panel {
+ background: var(--graphiql-panel, #252526);
+ border-right: 1px solid var(--graphiql-border, #333);
+ display: grid;
+ grid-template-rows: auto 1fr;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .header {
+ align-items: center;
+ border-bottom: 1px solid var(--graphiql-border, #333);
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ }
+
+ .clear {
+ background: none;
+ border: none;
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.75rem;
+ padding: 0;
+
+ &:hover {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+
+ .list {
+ display: grid;
+ gap: 0.125rem;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0.375rem 0;
+ }
+
+ .entry {
+ align-items: flex-start;
+ cursor: pointer;
+ display: grid;
+ gap: 0.125rem;
+ grid-template-columns: auto 1fr auto;
+ padding: 0.375rem 0.75rem;
+
+ &:hover {
+ background: var(--graphiql-bg, #1e1e1e);
+ }
+ }
+
+ .star {
+ background: none;
+ border: none;
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ font-size: 0.875rem;
+ padding: 0 0.375rem 0 0;
+
+ &.active {
+ color: var(--graphiql-accent, #e3b341);
+ }
+ }
+
+ .meta {
+ align-self: center;
+ min-width: 0;
+ }
+
+ .entry-title {
+ font-size: 0.8125rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .entry-time {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ }
+
+ .remove {
+ align-self: center;
+ background: none;
+ border: none;
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+
+ &:hover {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+
+ .empty {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ padding: 0.75rem;
+ }
+</style>
+
+<div class="panel">
+ <div class="header">
+ <span class="title">History</span>
+ {#if entries.length > 0}
+ <button class="clear" onclick={onClear} type="button">Clear</button>
+ {/if}
+ </div>
+ <div class="list">
+ {#if sorted.length === 0}
+ <div class="empty">No history yet.</div>
+ {:else}
+ {#each sorted as entry (entry.id)}
+ <div
+ aria-label="Load history entry"
+ class="entry"
+ onclick={(e) => onEntryClick(e, entry.id)}
+ onkeydown={(e) => onEntryKey(e, entry.id)}
+ role="button"
+ tabindex="0">
+ <button
+ aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
+ class="star"
+ class:active={entry.favorite}
+ onclick={(e) => { e.stopPropagation(); onFavorite(entry.id); }}
+ type="button">{entry.favorite ? "★" : "☆"}</button>
+ <div class="meta">
+ <div class="entry-title">{entry.title}</div>
+ <div class="entry-time">{formatTimestamp(entry.timestamp)}</div>
+ </div>
+ <button
+ aria-label="Remove entry"
+ class="remove"
+ onclick={(e) => { e.stopPropagation(); onRemove(entry.id); }}
+ type="button">×</button>
+ </div>
+ {/each}
+ {/if}
+ </div>
+</div>
diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte
new file mode 100644
index 0000000..e2c74fe
--- /dev/null
+++ b/source/library/components/ResultViewer.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import Editor from "./Editor.svelte";
+ import type { Extension } from "@codemirror/state";
+
+ type Props = {
+ theme?: Extension;
+ value: string;
+ };
+
+ let { theme, value }: Props = $props();
+
+ function noop(_v: string) {}
+</script>
+
+<style lang="scss">
+ .result {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ height: 100%;
+ min-height: 0;
+ }
+
+ .label {
+ background: var(--graphiql-panel, #252526);
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ padding: 0.25rem 0.75rem;
+ text-transform: uppercase;
+ }
+</style>
+
+<div class="result">
+ <div class="label">Response</div>
+ <Editor language="json" onChange={noop} readOnly {theme} {value}/>
+</div>
diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte
new file mode 100644
index 0000000..d87449d
--- /dev/null
+++ b/source/library/components/TabBar.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+ import { tick } from "svelte";
+ import type { Tab } from "../state/session.svelte.ts";
+
+ type Props = {
+ activeId: string;
+ onAdd: () => void;
+ onClose: (id: string) => void;
+ onRename: (id: string, title: string) => void;
+ onSelect: (id: string) => void;
+ tabs: Tab[];
+ };
+
+ let { activeId, onAdd, onClose, onRename, onSelect, tabs }: Props = $props();
+
+ let editingId = $state<string | null>(null);
+ let draft = $state<string>("");
+ let inputEl = $state<HTMLInputElement | null>(null);
+
+ async function startEditing(tab: Tab) {
+ editingId = tab.id;
+ draft = tab.title;
+ await tick();
+ inputEl?.select();
+ }
+
+ function commit() {
+ if (editingId === null) return;
+ onRename(editingId, draft);
+ editingId = null;
+ draft = "";
+ }
+
+ function cancel() {
+ editingId = null;
+ draft = "";
+ }
+
+ function onKeydown(event: KeyboardEvent) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ commit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancel();
+ }
+ }
+
+ function handleClose(event: MouseEvent, id: string) {
+ event.stopPropagation();
+ onClose(id);
+ }
+</script>
+
+<style lang="scss">
+ .tabbar {
+ align-items: stretch;
+ background: var(--graphiql-panel, #252526);
+ border-bottom: 1px solid var(--graphiql-border, #333);
+ display: flex;
+ font-size: 0.8125rem;
+ min-height: 2rem;
+ overflow-x: auto;
+ }
+
+ .tab {
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-right: 1px solid var(--graphiql-border, #333);
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ display: flex;
+ gap: 0.5rem;
+ padding: 0 0.75rem;
+
+ &.active {
+ background: var(--graphiql-bg, #1e1e1e);
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+
+ &:hover:not(.active) {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+
+ .title {
+ white-space: nowrap;
+ }
+
+ .edit {
+ background: var(--graphiql-bg, #1e1e1e);
+ border: 1px solid var(--graphiql-accent, #0e639c);
+ border-radius: 2px;
+ color: var(--graphiql-fg, #d4d4d4);
+ font-family: inherit;
+ font-size: inherit;
+ min-width: 6rem;
+ outline: none;
+ padding: 0.125rem 0.25rem;
+ }
+
+ .close {
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ opacity: 0.6;
+ padding: 0;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ .add {
+ background: none;
+ border: none;
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 0 0.75rem;
+
+ &:hover {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+</style>
+
+<div class="tabbar">
+ {#each tabs as tab (tab.id)}
+ <button
+ class="tab"
+ class:active={tab.id === activeId}
+ ondblclick={() => startEditing(tab)}
+ onclick={() => onSelect(tab.id)}
+ >
+ {#if editingId === tab.id}
+ <input
+ bind:this={inputEl}
+ bind:value={draft}
+ class="edit"
+ onblur={commit}
+ onclick={(e) => e.stopPropagation()}
+ onkeydown={onKeydown}
+ type="text"
+ />
+ {:else}
+ <span class="title">{tab.title}</span>
+ {/if}
+ <button
+ aria-label="Close tab"
+ class="close"
+ onclick={(e) => handleClose(e, tab.id)}
+ >×</button>
+ </button>
+ {/each}
+ <button aria-label="New tab" class="add" onclick={onAdd}>+</button>
+</div>
diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte
new file mode 100644
index 0000000..a17191c
--- /dev/null
+++ b/source/library/components/Toolbar.svelte
@@ -0,0 +1,150 @@
+<script lang="ts">
+ import type { OperationInfo } from "../graphql/operations.ts";
+
+ type Props = {
+ disabled: boolean;
+ docsAvailable?: boolean;
+ docsOpen?: boolean;
+ historyOpen?: boolean;
+ onRun: () => void;
+ onSelectOperation?: (name: string | null) => void;
+ onToggleDocs?: () => void;
+ onToggleHistory?: () => void;
+ operationName?: string | null;
+ operations?: OperationInfo[];
+ running: boolean;
+ schemaLoading: boolean;
+ };
+
+ let {
+ disabled,
+ docsAvailable = false,
+ docsOpen = false,
+ historyOpen = false,
+ onRun,
+ onSelectOperation,
+ onToggleDocs,
+ onToggleHistory,
+ operationName = null,
+ operations = [],
+ running,
+ schemaLoading
+ }: Props = $props();
+
+ const namedOperations = $derived(operations.filter((o) => o.name !== null));
+
+ function onPick(event: Event) {
+ const value = (event.currentTarget as HTMLSelectElement).value;
+ onSelectOperation?.(value || null);
+ }
+</script>
+
+<style lang="scss">
+ .toolbar {
+ align-items: center;
+ background: var(--graphiql-panel, #252526);
+ border-bottom: 1px solid var(--graphiql-border, #333);
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .run {
+ background: var(--graphiql-accent, #0e639c);
+ border: none;
+ border-radius: 3px;
+ color: #fff;
+ cursor: pointer;
+ font-size: 0.875rem;
+ padding: 0.375rem 1rem;
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+ }
+
+ .hint {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.75rem;
+ }
+
+ .picker {
+ background: var(--graphiql-bg, #1e1e1e);
+ border: 1px solid var(--graphiql-border, #333);
+ border-radius: 3px;
+ color: var(--graphiql-fg, #d4d4d4);
+ font-family: inherit;
+ font-size: 0.8125rem;
+ padding: 0.25rem 0.5rem;
+ }
+
+ .spacer {
+ flex: 1;
+ }
+
+ .toggle {
+ background: none;
+ border: 1px solid var(--graphiql-border, #333);
+ border-radius: 3px;
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.625rem;
+
+ &.active {
+ background: var(--graphiql-bg, #1e1e1e);
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+ }
+
+ &:hover:not(:disabled) {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+</style>
+
+<div class="toolbar">
+ <button class="run" {disabled} onclick={onRun}>
+ {running ? "Running…" : "Run"}
+ </button>
+ {#if namedOperations.length > 1}
+ <select
+ aria-label="Operation"
+ class="picker"
+ onchange={onPick}
+ value={operationName ?? ""}>
+ <option value="">Select operation…</option>
+ {#each namedOperations as op}
+ <option value={op.name}>{op.type} {op.name}</option>
+ {/each}
+ </select>
+ {/if}
+ <span class="hint">⌘/Ctrl + Enter</span>
+ {#if schemaLoading}
+ <span class="hint">Loading schema…</span>
+ {/if}
+ <span class="spacer"></span>
+ {#if onToggleHistory}
+ <button
+ aria-pressed={historyOpen}
+ class="toggle"
+ class:active={historyOpen}
+ onclick={onToggleHistory}
+ type="button">History</button>
+ {/if}
+ {#if onToggleDocs}
+ <button
+ aria-pressed={docsOpen}
+ class="toggle"
+ class:active={docsOpen}
+ disabled={!docsAvailable}
+ onclick={onToggleDocs}
+ type="button">Docs</button>
+ {/if}
+</div>
diff --git a/source/library/fetcher/http.ts b/source/library/fetcher/http.ts
new file mode 100644
index 0000000..3138226
--- /dev/null
+++ b/source/library/fetcher/http.ts
@@ -0,0 +1,30 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Fetcher, FetcherOptions } from "./types.ts";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export function createHttpFetcher(options: FetcherOptions): Fetcher {
+ const fetchImpl = options.fetch ?? globalThis.fetch;
+
+ return async (req) => {
+ const response = await fetchImpl(options.url, {
+ body: JSON.stringify({
+ operationName: req.operationName,
+ query: req.query,
+ variables: req.variables
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ ...options.headers,
+ ...req.headers
+ },
+ method: "POST"
+ });
+
+ return await response.json();
+ };
+}
diff --git a/source/library/fetcher/sse.ts b/source/library/fetcher/sse.ts
new file mode 100644
index 0000000..b6805dc
--- /dev/null
+++ b/source/library/fetcher/sse.ts
@@ -0,0 +1,18 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Fetcher, FetcherOptions } from "./types.ts";
+
+/*** EXPORT ------------------------------------------- ***/
+
+/**
+ * Server-Sent Events fetcher for graphql-sse protocol.
+ * Stub implementation — see PLAN.md stage v0.4 for full implementation.
+ */
+export function createSseFetcher(_options: FetcherOptions): Fetcher {
+ return () => {
+ throw new Error("SSE fetcher not yet implemented — see PLAN.md v0.4");
+ };
+}
diff --git a/source/library/fetcher/types.ts b/source/library/fetcher/types.ts
new file mode 100644
index 0000000..af849a9
--- /dev/null
+++ b/source/library/fetcher/types.ts
@@ -0,0 +1,20 @@
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type FetcherRequest = {
+ headers?: Record<string, string>;
+ operationName?: string | null;
+ query: string;
+ variables?: Record<string, unknown>;
+};
+
+export type FetcherResult = Record<string, unknown>;
+export type Fetcher = (req: FetcherRequest) => Promise<FetcherResult> | AsyncIterable<FetcherResult>;
+
+export type FetcherOptions = {
+ fetch?: typeof globalThis.fetch;
+ headers?: Record<string, string>;
+ url: string;
+};
diff --git a/source/library/fetcher/websocket.ts b/source/library/fetcher/websocket.ts
new file mode 100644
index 0000000..6376e76
--- /dev/null
+++ b/source/library/fetcher/websocket.ts
@@ -0,0 +1,18 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Fetcher, FetcherOptions } from "./types.ts";
+
+/*** EXPORT ------------------------------------------- ***/
+
+/**
+ * WebSocket fetcher for graphql-ws protocol.
+ * Stub implementation — see PLAN.md stage v0.4 for full implementation.
+ */
+export function createWsFetcher(_options: FetcherOptions): Fetcher {
+ return () => {
+ throw new Error("WebSocket fetcher not yet implemented — see PLAN.md v0.4");
+ };
+}
diff --git a/source/library/graphql/operations.ts b/source/library/graphql/operations.ts
new file mode 100644
index 0000000..b34aeee
--- /dev/null
+++ b/source/library/graphql/operations.ts
@@ -0,0 +1,56 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { parse } from "graphql";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type OperationInfo = {
+ name: string | null;
+ type: "mutation" | "query" | "subscription";
+};
+
+export function deriveTitle(query: string, ops: OperationInfo[]): string {
+ const first = ops[0];
+
+ if (first && first.name)
+ return first.name;
+
+ if (first)
+ return first.type;
+
+ const trimmed = query.trim();
+
+ if (!trimmed)
+ return "untitled";
+
+ return trimmed.slice(0, 20);
+}
+
+export function parseOperations(query: string): OperationInfo[] {
+ const trimmed = query.trim();
+
+ if (!trimmed)
+ return [];
+
+ try {
+ const doc = parse(trimmed);
+ const ops: OperationInfo[] = [];
+
+ for (const def of doc.definitions) {
+ if (def.kind !== "OperationDefinition")
+ continue;
+
+ ops.push({
+ name: def.name?.value ?? null,
+ type: def.operation
+ });
+ }
+
+ return ops;
+ } catch {
+ return [];
+ }
+}
diff --git a/source/library/index.ts b/source/library/index.ts
new file mode 100644
index 0000000..5aecff1
--- /dev/null
+++ b/source/library/index.ts
@@ -0,0 +1,28 @@
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+export { createHttpFetcher } from "./fetcher/http.ts";
+export { createLocalStorage, createMemoryStorage } from "./state/storage.ts";
+export { createSseFetcher } from "./fetcher/sse.ts";
+export { createWsFetcher } from "./fetcher/websocket.ts";
+export { default as GraphiQL } from "./GraphiQL.svelte";
+export { HistoryStore } from "./state/history.svelte.ts";
+export { lightTheme } from "./themes/light.ts";
+export { SchemaStore } from "./state/schema.svelte.ts";
+export { SessionStore } from "./state/session.svelte.ts";
+
+export type { Extension } from "@codemirror/state";
+
+export type {
+ Fetcher,
+ FetcherOptions,
+ FetcherRequest,
+ FetcherResult
+} from "./fetcher/types.ts";
+
+export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts";
+export type { OperationInfo } from "./graphql/operations.ts";
+export type { Storage } from "./state/storage.ts";
+export type { Tab, TabSeed } from "./state/session.svelte.ts";
diff --git a/source/library/runes.d.ts b/source/library/runes.d.ts
new file mode 100644
index 0000000..4b73482
--- /dev/null
+++ b/source/library/runes.d.ts
@@ -0,0 +1,37 @@
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+/**
+ * Ambient declarations for Svelte 5 runes so `deno check` can type-check
+ * `.svelte.ts` files. The runtime forms are injected by the Svelte compiler.
+ */
+
+declare function $state<T>(initial: T): T;
+declare function $state<T>(): T | undefined;
+
+declare namespace $state {
+ function raw<T>(initial: T): T;
+ function raw<T>(): T | undefined;
+ function snapshot<T>(value: T): T;
+}
+
+declare function $derived<T>(expression: T): T;
+
+declare namespace $derived {
+ function by<T>(fn: () => T): T;
+}
+
+declare function $effect(fn: () => void | (() => void)): void;
+
+declare namespace $effect {
+ function pre(fn: () => void | (() => void)): void;
+ function root(fn: () => void | (() => void)): () => void;
+ function tracking(): boolean;
+}
+
+declare function $props<T = Record<string, unknown>>(): T;
+declare function $bindable<T>(fallback?: T): T;
+declare function $inspect<T>(...values: T[]): { with: (fn: (type: "init" | "update", ...values: T[]) => void) => void };
+declare function $host<T extends HTMLElement = HTMLElement>(): T;
diff --git a/source/library/state/history-logic.ts b/source/library/state/history-logic.ts
new file mode 100644
index 0000000..5fce766
--- /dev/null
+++ b/source/library/state/history-logic.ts
@@ -0,0 +1,20 @@
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type HistoryEvictable = {
+ favorite: boolean;
+ timestamp: number;
+};
+
+export function evict<T extends HistoryEvictable>(entries: T[], max: number): T[] {
+ if (entries.length <= max)
+ return entries;
+
+ const favorites = entries.filter((e) => e.favorite);
+ const regular = entries.filter((e) => !e.favorite);
+ const keepRegular = regular.slice(0, Math.max(0, max - favorites.length));
+
+ return [...favorites, ...keepRegular].sort((a, b) => b.timestamp - a.timestamp);
+}
diff --git a/source/library/state/history.svelte.ts b/source/library/state/history.svelte.ts
new file mode 100644
index 0000000..2726283
--- /dev/null
+++ b/source/library/state/history.svelte.ts
@@ -0,0 +1,94 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { evict } from "./history-logic.ts";
+import type { Storage } from "./storage.ts";
+
+const MAX_ENTRIES = 100;
+const STORAGE_KEY = "history";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type HistoryEntry = {
+ favorite: boolean;
+ headers: string;
+ id: string;
+ operationName: string | null;
+ query: string;
+ timestamp: number;
+ title: string;
+ variables: string;
+};
+
+export type HistoryInput = {
+ headers: string;
+ operationName: string | null;
+ query: string;
+ title: string;
+ variables: string;
+};
+
+export class HistoryStore {
+ entries = $state<HistoryEntry[]>([]);
+
+ #storage: Storage;
+
+ constructor(storage: Storage) {
+ this.#storage = storage;
+
+ const restored = storage.get<HistoryEntry[]>(STORAGE_KEY);
+
+ if (Array.isArray(restored))
+ this.entries = restored.map((e) => ({
+ favorite: Boolean(e.favorite),
+ headers: e.headers ?? "{}",
+ id: e.id,
+ operationName: e.operationName ?? null,
+ query: e.query ?? "",
+ timestamp: e.timestamp ?? Date.now(),
+ title: e.title ?? "untitled",
+ variables: e.variables ?? "{}"
+ }));
+ }
+
+ add(input: HistoryInput) {
+ const entry: HistoryEntry = {
+ favorite: false,
+ headers: input.headers,
+ id: crypto.randomUUID(),
+ operationName: input.operationName,
+ query: input.query,
+ timestamp: Date.now(),
+ title: input.title,
+ variables: input.variables
+ };
+
+ this.entries = [entry, ...this.entries];
+ this.#evict();
+ }
+
+ clear() {
+ this.entries = this.entries.filter((e) => e.favorite);
+ }
+
+ favorite(id: string) {
+ const entry = this.entries.find((e) => e.id === id);
+
+ if (entry)
+ entry.favorite = !entry.favorite;
+ }
+
+ persist() {
+ this.#storage.set<HistoryEntry[]>(STORAGE_KEY, this.entries);
+ }
+
+ remove(id: string) {
+ this.entries = this.entries.filter((e) => e.id !== id);
+ }
+
+ #evict() {
+ this.entries = evict(this.entries, MAX_ENTRIES);
+ }
+}
diff --git a/source/library/state/schema.svelte.ts b/source/library/state/schema.svelte.ts
new file mode 100644
index 0000000..c5f148f
--- /dev/null
+++ b/source/library/state/schema.svelte.ts
@@ -0,0 +1,41 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import {
+ buildClientSchema,
+ getIntrospectionQuery,
+ printSchema,
+ type GraphQLSchema,
+ type IntrospectionQuery
+} from "graphql";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Fetcher } from "../fetcher/types.ts";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export class SchemaStore {
+ error = $state<string | null>(null);
+ loading = $state(false);
+ schema = $state<GraphQLSchema | null>(null);
+ sdl = $state<string>("");
+
+ async introspect(fetcher: Fetcher) {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ const result = await fetcher({ query: getIntrospectionQuery() });
+ const data = (result as { data: IntrospectionQuery }).data;
+ this.schema = buildClientSchema(data);
+ this.sdl = printSchema(this.schema);
+ } catch(err) {
+ this.error = String(err);
+ } finally {
+ this.loading = false;
+ }
+ }
+}
diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts
new file mode 100644
index 0000000..d9f52ff
--- /dev/null
+++ b/source/library/state/session.svelte.ts
@@ -0,0 +1,242 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Fetcher } from "../fetcher/types.ts";
+import {
+ deriveTitle,
+ parseOperations,
+ type OperationInfo
+} from "../graphql/operations.ts";
+import type { Storage } from "./storage.ts";
+
+const STORAGE_KEY = "session";
+
+type Snapshot = {
+ activeId: string;
+ tabs: Tab[];
+};
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type Tab = {
+ headers: string;
+ id: string;
+ operationName: string | null;
+ operations: OperationInfo[];
+ query: string;
+ result: string;
+ title: string;
+ titleDirty: boolean;
+ variables: string;
+};
+
+export type TabSeed = {
+ headers?: string;
+ operationName?: string | null;
+ query?: string;
+ title?: string;
+ variables?: string;
+};
+
+export class SessionStore {
+ activeId = $state<string>("");
+ tabs = $state<Tab[]>([]);
+ active = $derived(this.tabs.find((t) => t.id === this.activeId));
+
+ #storage: Storage;
+
+ constructor(storage: Storage) {
+ this.#storage = storage;
+ const restored = storage.get<Snapshot>(STORAGE_KEY);
+
+ if (restored && restored.tabs.length > 0) {
+ this.tabs = restored.tabs.map((t) => this.#hydrate(t));
+
+ this.activeId = this.tabs.some((t) => t.id === restored.activeId) ?
+ restored.activeId :
+ this.tabs[0].id;
+ } else {
+ const tab = this.#blank();
+ this.tabs = [tab];
+ this.activeId = tab.id;
+ }
+ }
+
+ addTab(seed?: TabSeed) {
+ const tab = seed ? this.#seeded(seed) : this.#blank();
+ this.tabs.push(tab);
+ this.activeId = tab.id;
+ }
+
+ closeTab(id: string) {
+ const idx = this.tabs.findIndex((t) => t.id === id);
+
+ if (idx === -1)
+ return;
+
+ if (this.tabs.length === 1) {
+ const fresh = this.#blank();
+ this.tabs = [fresh];
+ this.activeId = fresh.id;
+
+ return;
+ }
+
+ this.tabs.splice(idx, 1);
+
+ if (this.activeId === id)
+ this.activeId = this.tabs[Math.max(0, idx - 1)].id;
+ }
+
+ persist() {
+ this.#storage.set<Snapshot>(STORAGE_KEY, {
+ activeId: this.activeId,
+ tabs: this.tabs
+ });
+ }
+
+ renameActive(title: string) {
+ if (!this.active)
+ return;
+
+ this.active.title = title.trim() || "untitled";
+ this.active.titleDirty = true;
+ }
+
+ renameTab(id: string, title: string) {
+ const tab = this.tabs.find((t) => t.id === id);
+
+ if (!tab)
+ return;
+
+ tab.title = title.trim() || "untitled";
+ tab.titleDirty = true;
+ }
+
+ async run(fetcher: Fetcher): Promise<boolean> {
+ const tab = this.active;
+
+ if (!tab)
+ return false;
+
+ try {
+ const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {};
+ const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {};
+
+ const result = await fetcher({
+ headers,
+ operationName: tab.operationName,
+ query: tab.query,
+ variables
+ });
+
+ tab.result = JSON.stringify(result, null, 2);
+ return true;
+ } catch(err) {
+ tab.result = JSON.stringify({ error: String(err) }, null, 2);
+ return false;
+ }
+ }
+
+ overwriteActive(seed: TabSeed) {
+ const tab = this.active;
+
+ if (!tab)
+ return;
+
+ const query = seed.query ?? "";
+ tab.headers = seed.headers ?? "{}";
+ tab.operations = parseOperations(query);
+ tab.operationName = seed.operationName ?? null;
+ tab.query = query;
+ tab.result = "";
+ tab.variables = seed.variables ?? "{}";
+
+ if (seed.title && !tab.titleDirty)
+ tab.title = seed.title;
+ else if (!tab.titleDirty)
+ tab.title = deriveTitle(query, tab.operations);
+ }
+
+ selectOperation(id: string, name: string | null) {
+ const tab = this.tabs.find((t) => t.id === id);
+
+ if (tab)
+ tab.operationName = name;
+ }
+
+ selectTab(id: string) {
+ if (this.tabs.some((t) => t.id === id))
+ this.activeId = id;
+ }
+
+ updateQuery(id: string, query: string) {
+ const tab = this.tabs.find((t) => t.id === id);
+
+ if (!tab)
+ return;
+
+ tab.query = query;
+
+ const ops = parseOperations(query);
+ tab.operations = ops;
+
+ if (ops.length === 0)
+ tab.operationName = null;
+ else if (ops.length === 1)
+ tab.operationName = ops[0].name;
+ else if (tab.operationName && !ops.some((o) => o.name === tab.operationName))
+ tab.operationName = null;
+
+ if (!tab.titleDirty)
+ tab.title = deriveTitle(query, ops);
+ }
+
+ #blank(): Tab {
+ return {
+ headers: "{}",
+ id: crypto.randomUUID(),
+ operationName: null,
+ operations: [],
+ query: "",
+ result: "",
+ title: "untitled",
+ titleDirty: false,
+ variables: "{}"
+ };
+ }
+
+ #hydrate(raw: Tab): Tab {
+ return {
+ headers: raw.headers ?? "{}",
+ id: raw.id,
+ operationName: raw.operationName ?? null,
+ operations: raw.operations ?? parseOperations(raw.query ?? ""),
+ query: raw.query ?? "",
+ result: raw.result ?? "",
+ title: raw.title ?? "untitled",
+ titleDirty: raw.titleDirty ?? raw.title !== "untitled",
+ variables: raw.variables ?? "{}"
+ };
+ }
+
+ #seeded(seed: TabSeed): Tab {
+ const query = seed.query ?? "";
+ const operations = parseOperations(query);
+ const title = seed.title ?? deriveTitle(query, operations);
+
+ return {
+ headers: seed.headers ?? "{}",
+ id: crypto.randomUUID(),
+ operationName: seed.operationName ?? null,
+ operations,
+ query,
+ result: "",
+ title,
+ titleDirty: Boolean(seed.title),
+ variables: seed.variables ?? "{}"
+ };
+ }
+}
diff --git a/source/library/state/storage.ts b/source/library/state/storage.ts
new file mode 100644
index 0000000..a24096a
--- /dev/null
+++ b/source/library/state/storage.ts
@@ -0,0 +1,77 @@
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type Storage = {
+ get<T>(key: string): T | null;
+ remove(key: string): void;
+ set<T>(key: string, value: T): void;
+};
+
+export function createLocalStorage(namespace: string): Storage {
+ const prefix = `${namespace}:`;
+
+ function available(): boolean {
+ try {
+ return typeof globalThis.localStorage !== "undefined";
+ } catch {
+ return false;
+ }
+ }
+
+ return {
+ get<T>(key: string): T | null {
+ if (!available())
+ return null;
+
+ const raw = globalThis.localStorage.getItem(prefix + key);
+
+ if (raw === null)
+ return null;
+
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ },
+ remove(key: string): void {
+ if (!available())
+ return;
+
+ globalThis.localStorage.removeItem(prefix + key);
+ },
+ set<T>(key: string, value: T): void {
+ if (!available())
+ return;
+
+ globalThis.localStorage.setItem(prefix + key, JSON.stringify(value));
+ }
+ };
+}
+
+export function createMemoryStorage(): Storage {
+ const store = new Map<string, string>();
+
+ return {
+ get<T>(key: string): T | null {
+ const raw = store.get(key);
+
+ if (raw === undefined)
+ return null;
+
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ },
+ remove(key: string): void {
+ store.delete(key);
+ },
+ set<T>(key: string, value: T): void {
+ store.set(key, JSON.stringify(value));
+ }
+ };
+}
diff --git a/source/library/themes/light.ts b/source/library/themes/light.ts
new file mode 100644
index 0000000..daaede2
--- /dev/null
+++ b/source/library/themes/light.ts
@@ -0,0 +1,75 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { EditorView } from "@codemirror/view";
+import { tags as t } from "@lezer/highlight";
+
+/*** EXPORT ------------------------------------------- ***/
+
+const BG = "#fafafa";
+const BORDER = "#e0e0e0";
+const FG = "#24292e";
+const GUTTER_BG = "#f3f3f3";
+const GUTTER_FG = "#9ca3af";
+const SELECTION = "#b3d4fc";
+
+const base = EditorView.theme({
+ "&": {
+ backgroundColor: BG,
+ color: FG
+ },
+ "&.cm-focused": {
+ outline: "none"
+ },
+ ".cm-activeLine": {
+ backgroundColor: "#f0f0f0"
+ },
+ ".cm-activeLineGutter": {
+ backgroundColor: "transparent",
+ color: FG
+ },
+ ".cm-content": {
+ caretColor: "#1f6feb"
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeftColor: "#1f6feb"
+ },
+ ".cm-gutters": {
+ backgroundColor: GUTTER_BG,
+ border: "none",
+ borderRight: `1px solid ${BORDER}`,
+ color: GUTTER_FG
+ },
+ ".cm-matchingBracket": {
+ backgroundColor: "#dbeafe",
+ outline: "1px solid #93c5fd"
+ },
+ ".cm-selectionBackground, &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
+ backgroundColor: SELECTION
+ }
+}, { dark: false });
+
+const highlight = HighlightStyle.define([
+ { color: "#d73a49", tag: t.keyword },
+ { color: "#6f42c1", tag: [t.name, t.deleted, t.character, t.macroName] },
+ { color: "#6f42c1", tag: [t.propertyName] },
+ { color: "#032f62", tag: [t.string, t.special(t.string)] },
+ { color: "#005cc5", tag: [t.number, t.bool, t.null, t.atom] },
+ { color: "#6a737d", fontStyle: "italic", tag: t.comment },
+ { color: "#22863a", tag: [t.typeName, t.className] },
+ { color: "#e36209", tag: [t.variableName, t.labelName] },
+ { color: "#d73a49", tag: [t.operator, t.operatorKeyword] },
+ { color: "#6a737d", tag: [t.meta, t.documentMeta] },
+ { color: "#22863a", tag: [t.tagName] },
+ { color: "#6f42c1", tag: [t.attributeName] },
+ { color: "#e36209", tag: [t.heading] },
+ { color: "#032f62", tag: [t.link] },
+ { fontWeight: "bold", tag: [t.strong] },
+ { fontStyle: "italic", tag: [t.emphasis] },
+ { tag: t.strikethrough, textDecoration: "line-through" }
+]);
+
+export const lightTheme = [base, syntaxHighlighting(highlight)];
diff --git a/tests/history.test.ts b/tests/history.test.ts
new file mode 100644
index 0000000..ecd7785
--- /dev/null
+++ b/tests/history.test.ts
@@ -0,0 +1,77 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { assertEquals } from "jsr:@std/assert@^1.0.0";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { evict } from "../source/library/state/history-logic.ts";
+
+type Entry = {
+ favorite: boolean;
+ id: string;
+ timestamp: number;
+};
+
+function entry(id: string, timestamp: number, favorite = false): Entry {
+ return { favorite, id, timestamp };
+}
+
+/*** TESTS -------------------------------------------- ***/
+
+Deno.test("evict keeps everything when under cap", () => {
+ const entries = [entry("a", 3), entry("b", 2), entry("c", 1)];
+ assertEquals(evict(entries, 5), entries);
+});
+
+Deno.test("evict drops the oldest non-favorites above cap", () => {
+ const entries = [
+ entry("a", 5),
+ entry("b", 4),
+ entry("c", 3),
+ entry("d", 2),
+ entry("e", 1)
+ ];
+ const kept = evict(entries, 3);
+ assertEquals(kept.map((e) => e.id), ["a", "b", "c"]);
+});
+
+Deno.test("evict never drops favorites", () => {
+ const entries = [
+ entry("a", 10),
+ entry("b", 9),
+ entry("fav-old", 1, true),
+ entry("c", 8),
+ entry("d", 7)
+ ];
+ const kept = evict(entries, 3);
+
+ assertEquals(kept.some((e) => e.id === "fav-old"), true);
+ assertEquals(kept.length, 3);
+});
+
+Deno.test("evict can exceed cap when favorites alone do so", () => {
+ const entries = [
+ entry("fav-1", 5, true),
+ entry("fav-2", 4, true),
+ entry("fav-3", 3, true),
+ entry("regular", 2)
+ ];
+ const kept = evict(entries, 2);
+
+ assertEquals(kept.length, 3);
+ assertEquals(kept.every((e) => e.favorite), true);
+});
+
+Deno.test("evict sorts by timestamp descending", () => {
+ const entries = [
+ entry("c", 1),
+ entry("a", 3),
+ entry("b", 2),
+ entry("d", 0)
+ ];
+ const kept = evict(entries, 3);
+ assertEquals(kept.map((e) => e.id), ["a", "b", "c"]);
+});
diff --git a/tests/operations.test.ts b/tests/operations.test.ts
new file mode 100644
index 0000000..99357ea
--- /dev/null
+++ b/tests/operations.test.ts
@@ -0,0 +1,64 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { assertEquals } from "jsr:@std/assert@^1.0.0";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { deriveTitle, parseOperations } from "../source/library/graphql/operations.ts";
+
+/*** TESTS -------------------------------------------- ***/
+
+Deno.test("parseOperations returns empty for blank query", () => {
+ assertEquals(parseOperations(""), []);
+ assertEquals(parseOperations(" "), []);
+});
+
+Deno.test("parseOperations returns empty on syntax error", () => {
+ assertEquals(parseOperations("query { ..."), []);
+});
+
+Deno.test("parseOperations captures a single named query", () => {
+ const ops = parseOperations("query Foo { viewer { id } }");
+ assertEquals(ops, [{ name: "Foo", type: "query" }]);
+});
+
+Deno.test("parseOperations returns null name for anonymous ops", () => {
+ const ops = parseOperations("{ viewer { id } }");
+ assertEquals(ops, [{ name: null, type: "query" }]);
+});
+
+Deno.test("parseOperations captures multiple operations", () => {
+ const ops = parseOperations(`
+ query Foo { a }
+ mutation Bar { b }
+ subscription Baz { c }
+ `);
+ assertEquals(ops, [
+ { name: "Foo", type: "query" },
+ { name: "Bar", type: "mutation" },
+ { name: "Baz", type: "subscription" }
+ ]);
+});
+
+Deno.test("deriveTitle prefers the first operation name", () => {
+ const ops = parseOperations("query Foo { a }");
+ assertEquals(deriveTitle("query Foo { a }", ops), "Foo");
+});
+
+Deno.test("deriveTitle falls back to operation type", () => {
+ const ops = parseOperations("mutation { a }");
+ assertEquals(deriveTitle("mutation { a }", ops), "mutation");
+});
+
+Deno.test("deriveTitle falls back to the first 20 chars when unparsable", () => {
+ const query = "this is not valid graphql at all";
+ assertEquals(deriveTitle(query, parseOperations(query)), "this is not valid gr");
+});
+
+Deno.test("deriveTitle returns 'untitled' for empty input", () => {
+ assertEquals(deriveTitle("", []), "untitled");
+ assertEquals(deriveTitle(" ", []), "untitled");
+});
diff --git a/tests/storage.test.ts b/tests/storage.test.ts
new file mode 100644
index 0000000..7d6ba73
--- /dev/null
+++ b/tests/storage.test.ts
@@ -0,0 +1,83 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { assertEquals } from "jsr:@std/assert@^1.0.0";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import {
+ createLocalStorage,
+ createMemoryStorage
+} from "../source/library/state/storage.ts";
+
+/*** TESTS -------------------------------------------- ***/
+
+Deno.test("memory storage round-trips objects", () => {
+ const storage = createMemoryStorage();
+ storage.set("k", { hello: "world" });
+ assertEquals(storage.get<{ hello: string }>("k"), { hello: "world" });
+});
+
+Deno.test("memory storage returns null for missing keys", () => {
+ const storage = createMemoryStorage();
+ assertEquals(storage.get("missing"), null);
+});
+
+Deno.test("memory storage remove clears a key", () => {
+ const storage = createMemoryStorage();
+ storage.set("k", 42);
+ storage.remove("k");
+ assertEquals(storage.get("k"), null);
+});
+
+Deno.test("memory storage instances are isolated", () => {
+ const a = createMemoryStorage();
+ const b = createMemoryStorage();
+ a.set("shared", 1);
+ assertEquals(b.get("shared"), null);
+});
+
+Deno.test("local storage namespaces keys", () => {
+ globalThis.localStorage.clear();
+
+ const alpha = createLocalStorage("alpha");
+ const beta = createLocalStorage("beta");
+
+ alpha.set("shared", { tag: "a" });
+ beta.set("shared", { tag: "b" });
+
+ assertEquals(alpha.get<{ tag: string }>("shared"), { tag: "a" });
+ assertEquals(beta.get<{ tag: string }>("shared"), { tag: "b" });
+ assertEquals(globalThis.localStorage.getItem("alpha:shared"), JSON.stringify({ tag: "a" }));
+ assertEquals(globalThis.localStorage.getItem("beta:shared"), JSON.stringify({ tag: "b" }));
+
+ globalThis.localStorage.clear();
+});
+
+Deno.test("local storage remove respects the namespace", () => {
+ globalThis.localStorage.clear();
+
+ const alpha = createLocalStorage("alpha");
+ const beta = createLocalStorage("beta");
+
+ alpha.set("k", 1);
+ beta.set("k", 2);
+
+ alpha.remove("k");
+ assertEquals(alpha.get("k"), null);
+ assertEquals(beta.get<number>("k"), 2);
+
+ globalThis.localStorage.clear();
+});
+
+Deno.test("local storage returns null on malformed JSON", () => {
+ globalThis.localStorage.clear();
+ globalThis.localStorage.setItem("alpha:bad", "not-json");
+
+ const alpha = createLocalStorage("alpha");
+ assertEquals(alpha.get("bad"), null);
+
+ globalThis.localStorage.clear();
+});