aboutsummaryrefslogtreecommitdiff
path: root/source
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 /source
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
Diffstat (limited to 'source')
-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
24 files changed, 2335 insertions, 0 deletions
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)];