aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.npmignore10
-rw-r--r--PLAN.md218
-rw-r--r--README.md14
-rw-r--r--package.json50
-rw-r--r--playground/index.html26
-rw-r--r--playground/src/App.svelte31
-rw-r--r--playground/src/favicon.svg15
-rw-r--r--playground/src/main.ts10
-rw-r--r--source/library/GraphiQL.svelte392
-rw-r--r--source/library/components/DocExplorer.svelte134
-rw-r--r--source/library/components/DocExplorer/FieldView.svelte46
-rw-r--r--source/library/components/DocExplorer/TypeLink.svelte16
-rw-r--r--source/library/components/DocExplorer/TypeView.svelte71
-rw-r--r--source/library/components/Editor.svelte9
-rw-r--r--source/library/components/HeadersEditor.svelte34
-rw-r--r--source/library/components/HistoryPanel.svelte225
-rw-r--r--source/library/components/ResultViewer.svelte97
-rw-r--r--source/library/components/Splitter.svelte95
-rw-r--r--source/library/components/TabBar.svelte122
-rw-r--r--source/library/components/Toolbar.svelte130
-rw-r--r--source/library/fetcher/apq.ts3
-rw-r--r--source/library/fetcher/sse.ts12
-rw-r--r--source/library/graphiql.d.ts1
-rw-r--r--source/library/graphql/markdown.ts45
-rw-r--r--source/library/index.ts7
-rw-r--r--source/library/runes.d.ts37
-rw-r--r--source/library/state/history.svelte.ts2
-rw-r--r--source/library/state/session-io.ts116
-rw-r--r--source/library/state/session.svelte.ts34
-rw-r--r--source/library/styles/theme.scss47
-rw-r--r--source/library/themes/light.ts138
-rw-r--r--svelte.config.js17
-rw-r--r--tests/apq.test.ts70
-rw-r--r--tests/format.test.ts3
-rw-r--r--tests/history.test.ts18
-rw-r--r--tests/keyboard.test.ts24
-rw-r--r--tests/operations.test.ts8
-rw-r--r--tests/session-io.test.ts51
-rw-r--r--tests/storage.test.ts80
-rw-r--r--tests/timing.test.ts21
-rw-r--r--tsconfig.json5
-rw-r--r--vite.config.ts21
-rw-r--r--vitest.config.ts11
44 files changed, 1361 insertions, 1156 deletions
diff --git a/.gitignore b/.gitignore
index 88b84bb..fc6689a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,6 @@ package-lock.json
Thumbs.db
# Directories
-.claude
.svelte-kit
dist/
node_modules/
diff --git a/.npmignore b/.npmignore
index f1b7da2..56f8952 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,9 +1,11 @@
-source/
-tests/
-playground/
-PLAN.md
+# Files
.gitignore
svelte.config.js
tsconfig.json
vite.config.ts
vitest.config.ts
+
+# Directories
+playground/
+source/
+tests/
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index f97e7ac..0000000
--- a/PLAN.md
+++ /dev/null
@@ -1,218 +0,0 @@
-# PLAN.md — @eol/graphiql
-
-A Svelte 5 GraphiQL alternative. JSR-published under the EOL scope.
-
-## Current state (v0.2 complete)
-
-Shipped:
-
-- HTTP fetcher with injectable `fetch` impl
-- Schema introspection and SDL caching
-- Session store with tabs, persistence, and rename
-- TabBar with double-click inline rename (Enter commits, Escape cancels, blur commits)
-- Toolbar with Cmd/Ctrl+Enter run shortcut
-- Variables + Headers panes (switchable in bottom-left)
-- Debounced localStorage persistence (300ms) with `beforeunload` flush
-- Memory storage fallback for SSR
-- CodeMirror 6 editor wrapper via `cm6-graphql` with dynamic import (SSR-safe)
-- `readOnly` prop on Editor, used by ResultViewer
-- Reactive schema updates via `$effect` calling `updateSchema()`
-- Public API surface in `src/lib/index.ts`
-
-Stub files needing implementation: `src/lib/fetcher/sse.ts`, `src/lib/fetcher/websocket.ts`.
-
-## Coding conventions
-
-Non-negotiable. Match existing files exactly:
-
-- Double quotes, semicolons, two-space indent
-- No trailing spaces, no unnecessary whitespace
-- Svelte component structure: `<script lang="ts">`, then `<style lang="scss">`, then markup
-- Object literal keys alphabetical unless semantic ordering matters
-- TypeScript strict mode, no `any`
-- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`) — no stores, no `writable`
-- No direct `window` or `document` access — use `$app/environment`'s `browser`, or `typeof globalThis.X !== "undefined"` guards
-- No React, no Node-specific APIs (Deno/Bun first)
-- Fix mistakes in place; don't re-scaffold
-
-## Stages
-
-### v0.2.1 — Polish (small, worth doing before v0.3)
-
-**Tab auto-titling.** When typing in a brand-new tab, auto-generate a title from the first meaningful token of the query (operation name if present, else "query"/"mutation"/"subscription", else first 20 chars). Only auto-rename if the user hasn't manually renamed. Track with a `titleDirty: boolean` field on `Tab`. Set it to `true` in `renameTab()` and `renameActive()`.
-
-**Operation detection.** Parse the query with `graphql`'s `parse()` and set `tab.operationName` automatically when there's a single named operation. If multiple operations exist and none selected, surface a picker next to the Run button. Keep parse errors silent — the CM6 linter already shows them.
-
-**Theme prop.** Currently hardcoded to `oneDark`. Extract to an optional `theme?: Extension` prop on the root component, defaulting to `oneDark`. Consumers can pass any CM6 theme extension. A light theme preset (`import("@codemirror/theme")` basics or a small hand-rolled one) should ship alongside so people can toggle without adding a dependency.
-
-### v0.3 — Doc explorer and history
-
-**Doc explorer (`components/DocExplorer.svelte`).** Right-hand collapsible pane. Walks `schema.schema` (the built `GraphQLSchema` object). Breadcrumb navigation: Root → Type → Field → Arg. Clicking a field pushes onto a navigation stack. Clicking a breadcrumb crumb pops to that level.
-
-Structure:
-
-- `DocExplorer.svelte` — container, manages nav stack via `$state`
-- `DocExplorer/TypeView.svelte` — renders a `GraphQLObjectType`, `GraphQLInterfaceType`, `GraphQLUnionType`, `GraphQLEnumType`, `GraphQLInputObjectType`, or `GraphQLScalarType`
-- `DocExplorer/FieldView.svelte` — renders a single field with args and return type
-- `DocExplorer/TypeLink.svelte` — clickable type reference (unwraps `NonNull`/`List`)
-
-Use the `graphql` package's `isObjectType`, `isInterfaceType`, etc. guards — don't duck-type.
-
-Toggle visibility with a toolbar button. Width around 320px. Collapsed state persisted via the same storage adapter under a `"docExplorer"` key.
-
-**History panel (`components/HistoryPanel.svelte`).** Drawer from the left. Logs every successful query execution (query + variables + headers + timestamp + operationName). New history store: `state/history.svelte.ts` with `HistoryStore` class, methods `add(entry)`, `remove(id)`, `clear()`, `favorite(id)`. Cap at 100 entries; favorites never evict. Clicking an entry loads it into the active tab (overwriting). Shift-click opens in a new tab.
-
-Persist under `"history"` key. Same debounce pattern as session.
-
-### v0.4 — Subscriptions (SSE + WebSocket)
-
-Replace the stubs in `fetcher/sse.ts` and `fetcher/websocket.ts`.
-
-**SSE implementation.** Use `graphql-sse` protocol. The fetcher signature already allows `AsyncIterable<FetcherResult>` as a return type — use it. Stream each `next` event as an iteration. Close the `EventSource` on `complete` or consumer abort.
-
-**WebSocket implementation.** Use the `graphql-ws` library (JSR: `npm:graphql-ws`). Return an `AsyncIterable` that yields each `next` message. Handle `connection_init`, `ping`/`pong`, subscription IDs, and clean teardown.
-
-**Root component update.** `GraphiQL.svelte`'s `run()` needs to detect `AsyncIterable` return values and stream them into `tab.result`. Append each payload with a timestamp header, or replace on each — make it a prop choice (`subscriptionMode: "replace" | "append"`, default `"append"`).
-
-**Protocol detection.** Add a prop to pick transport per-operation: if the query contains a subscription, prefer WS; otherwise HTTP. Let consumers override by passing a custom fetcher that dispatches internally.
-
-### v0.5 — Theming and plugin slots
-
-**Theming.** Two layers: the editor theme (a CM6 `Extension` passed as a prop per v0.2.1) and the surrounding chrome theme (CSS custom properties like `--graphiql-bg`, `--graphiql-fg`). Extract the chrome variables into `src/lib/styles/theme.scss` with light/dark mode variables gated by `@media (prefers-color-scheme)` and an override class. Ship at least two CM6 theme presets (dark via `oneDark`, light via a small hand-rolled `EditorView.theme({...})`). Document the full variable list and theme prop API in README.
-
-**Plugin slots.** Use Svelte snippets (the `{@render}` / `Snippet` type from Svelte 5). Add optional props to `GraphiQL.svelte`:
-
-- `toolbarExtras?: Snippet` — rendered after the Run button
-- `resultFooter?: Snippet<[{ result: string }]>` — rendered below the result pane
-- `tabExtras?: Snippet<[{ tab: Tab }]>` — rendered inside each tab button
-
-This keeps the plugin story type-safe without a bespoke registry.
-
-### v0.6 — Ergonomics (keyboard + format)
-
-**Keyboard shortcuts.** Extend the existing `onKeydown` handler in `GraphiQL.svelte` (already bound via `<svelte:window>`). Add, in priority order:
-
-- `Cmd/Ctrl + Shift + Enter` — new tab (`session.addTab()`). We do **not** use `Cmd+T`; browsers intercept it and `preventDefault` is unreliable across hosts. Document this in README — if an embedder is running in Tauri/Electron they can remap to `Cmd+T` themselves.
-- `Cmd/Ctrl + Shift + W` — close active tab (`session.closeTab(session.activeId)`). Same reasoning — `Cmd+W` is browser-owned.
-- `Cmd/Ctrl + Alt + Right/Left` — next/prev tab. Wraps around end-to-start. No-op on a single tab.
-- `Cmd/Ctrl + Shift + F` — format active query (see below).
-
-Extract the handler into `state/keyboard.ts` as a pure function `matchShortcut(event) → Action | null` so `tests/keyboard.test.ts` can cover matrix cases without a DOM.
-
-**Query formatting.** New file `graphql/format.ts` exporting `format(query: string): string` that calls `print(parse(query))` from `graphql`. On parse failure, return the original string unchanged — don't throw. Wire into:
-
-- A `Format` button in `Toolbar.svelte`, placed between the operation picker and `extras`. Disabled when the active tab query is empty or unparseable (check via `operations.length === 0 && query.trim().length > 0`? No — cheaper to attempt `format()` and compare; if equal to input *and* parse would have failed, disable. Actually simplest: always enable, no-op on unparseable).
-- `Cmd/Ctrl + Shift + F` shortcut above. Route through `session.formatActive()` which sets `tab.query = format(tab.query)` and re-derives operations.
-
-Gotcha: `print()` strips comments. Document this — we are not going to keep a CST round-trip for v0.6.
-
-### v0.7 — Session portability
-
-**Export/import session JSON.** Extend `SessionStore` with:
-
-- `exportTab(id: string): TabExport | null` — strips `id`, `result`, `operations` (derivable), keeps `headers`, `operationName`, `query`, `title`, `variables`, and a version tag.
-- `exportAll(): SessionExport` — `{ version: 1, exportedAt: ISO, tabs: TabExport[] }`.
-- `importTabs(data: SessionExport, opts: { mode: "replace" | "append" }): ImportResult` — validates the `version` field and tab shape (lightweight hand-rolled check, no zod); rejects unknown versions with a descriptive error; returns `{ added: number, skipped: number, errors: string[] }`.
-
-Shape validation lives in `state/session-io.ts` so it can be unit tested without the store. Treat imported JSON as untrusted — reject if any string field exceeds 1 MB (covers accidental mega-pastes), cap tab count at 50 per import.
-
-**UI surface.** Two buttons in the History panel header (already has `Clear`) — `Export` and `Import`. Export downloads via `Blob` + object URL, filename `graphiql-session-{ISO}.json`. Import uses a hidden `<input type="file" accept="application/json">` triggered by the button. Show an `ImportResult` toast-style row inline in the panel on completion; no toast library.
-
-### v0.8 — Layout resize
-
-**Splitter component.** `components/Splitter.svelte`, no external deps. Props: `orientation: "horizontal" | "vertical"`, `min: number`, `max: number`, `value: number`, `onChange: (v: number) => void`. Internal: `pointerdown` captures the pointer, `pointermove` computes delta against the parent element's bounding rect, `pointerup` releases capture. Writes to `value` via `onChange` on every move (consumer is expected to throttle via `$effect` if needed — store writes are already debounced).
-
-**Wire into `GraphiQL.svelte`.** Replace the fixed `grid-template-columns` / `grid-template-rows` with CSS custom properties driven by `$state`:
-
-- `--graphiql-left-width` (default `1fr`, resized via middle splitter between query column and result)
-- `--graphiql-bottom-height` (default split between editor and variables/headers pane — currently `2fr auto 1fr`, change the `1fr` to `var(--graphiql-bottom-height, 1fr)`)
-- `--graphiql-docs-width` (default `320px`, only present when docs open)
-- `--graphiql-history-width` (default `260px`, only present when history open)
-
-Persist each to storage under `layout.{key}` keys, hydrated in the constructor. Storage accepts numbers; we re-stringify as `{n}px` or fractions depending on which axis.
-
-Three splitters total: between history/left, between left/right, between right/docs, and a horizontal one inside `.left` between the query editor and the variables/headers pane. Keep total splitter DOM cost at four elements.
-
-### v0.9 — Timing display
-
-**Instrumentation at the run boundary.** Modify `SessionStore.run()` only — don't touch the `Fetcher` signature. Record:
-
-- `tab.timing.startMs = performance.now()` before the fetcher call.
-- `tab.timing.firstByteMs = performance.now()` on first payload (either the awaited object or the first iterator step).
-- `tab.timing.endMs = performance.now()` on completion.
-- For subscriptions: `tab.streamIntervals: number[]` — delta between each successive payload, capped at 500 entries per run.
-
-Add `timing` and `streamIntervals` fields to `Tab`. Render a small row under `ResultViewer` (not a footer snippet — that's for consumers) showing `→ 42ms · first byte 18ms` for one-shot, or `→ 12 messages · median 430ms` for streams. Compute the median inline; no helpers dir.
-
-Subscription streams don't have a single end; treat `endMs` as the time of the last payload and update it on each step so consumers see a rolling duration while streaming.
-
-**Do not** attempt to surface server-side trace extensions (Apollo Tracing, `response.extensions.tracing`) in v0.9 — that's a separate feature with its own UI. Document as future work.
-
-### v0.10 — Persisted queries (APQ)
-
-**Wrapper fetcher.** New file `fetcher/apq.ts`, exported as `createApqFetcher(inner: Fetcher, options?: ApqOptions): Fetcher`. Implements the Apollo Automatic Persisted Queries protocol:
-
-1. Compute `sha256Hash` of the query string with `globalThis.crypto.subtle.digest("SHA-256", ...)`. Cache per-query in a `Map<string, string>` to avoid rehashing.
-2. First attempt — send `{ operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }` without the `query`. Wrap the inner fetcher by passing a synthetic `FetcherRequest` with `query: ""` and stash the hash in `headers["x-apq-hash"]`? No — cleaner: don't reuse `inner`, build the body ourselves. This means APQ needs its own HTTP fetcher variant or needs to extend the inner fetcher contract.
-
- **Decision:** APQ is HTTP-specific. Implement `createApqFetcher(options: FetcherOptions): Fetcher` that builds the body directly, bypassing `createHttpFetcher`. Share the header-merging logic via a small helper `fetcher/http-body.ts`. SSE/WS get no APQ support in v0.10 — document as out of scope.
-
-3. If response contains `{ errors: [{ message: "PersistedQueryNotFound", ... }] }`, retry with `{ query, operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }`.
-4. Cache the hash → query mapping in memory for the fetcher's lifetime; no disk persistence in v0.10 (servers already cache).
-
-`ApqOptions`:
-
-- Everything from `FetcherOptions`
-- `disable?: boolean` — escape hatch for debugging, forces full query on every request.
-
-**Export.** Add `createApqFetcher` and `ApqOptions` to `source/library/index.ts`. No UI changes — this is consumer-facing plumbing.
-
-### v1.0 — Stabilization
-
-Not a feature stage. Before tagging v1.0:
-
-- README audit: every exported name has an example.
-- `deno publish --dry-run` clean, no type errors.
-- Every `*.svelte.ts` store has a companion `tests/*.test.ts`.
-- Theme variables documented in one table in the README.
-- One known-good end-to-end story: run against a real GraphQL endpoint (e.g., the Countries API) via an example app in `examples/` (optional, can skip if the README reproducer is enough).
-
-## Publishing
-
-The project publishes to JSR as `@eol/graphiql`. Current `jsr.json` and `deno.json` are set up for v0.2.0. Bump versions with ChronVer per NetOpWibby's convention. The `exports` points at `src/lib/index.ts`; JSR ships `.svelte` files as-is and the consumer's SvelteKit build compiles them. **Do not pre-compile Svelte components** — that breaks SSR and prevents consumers from using their own Svelte version.
-
-Run `deno publish --dry-run` before tagging.
-
-## Known gotchas
-
-**CM6 extension composition.** CodeMirror 6 is extension-based — every feature is an opt-in `Extension`. The current `Editor.svelte` bundles a reasonable default set (line numbers, history, bracket matching, close brackets, indent-on-input, default + history keymaps, syntax highlighting, oneDark theme). If consumers want a leaner editor (e.g., for the result viewer), the cleanest path is to make the extension array a prop. Not done yet — v0.2.1 `theme` prop is the entry point for this refactor.
-
-**Schema reactivity.** Schema changes are handled by an `$effect` that calls `cm6-graphql`'s `updateSchema(view, newSchema)`. This is the documented CM6 pattern — do not recreate the editor on schema change, as that loses cursor position, history, and undo stack. If `schema` is invalid SDL, the catch block silently skips and the editor keeps working without schema awareness.
-
-**`crypto.randomUUID()`** works in Deno, modern browsers, and Bun. It does not work in Node.js before 14.17. Since we're Deno/Bun first this is fine; flag in docs if someone reports Node issues.
-
-**Nested button in TabBar.** The close `×` button is nested inside the tab `<button>`. Technically invalid HTML but works everywhere. If a11y linters complain, refactor the outer `<button>` to `<div role="tab" tabindex="0">` with explicit keyboard handlers.
-
-**Schema introspection failures.** Some GraphQL servers disable introspection in production. Surface the error clearly (already done in `.status` bar) but make sure queries still run without schema — the CM6 editor falls back to plain GraphQL syntax highlighting when `sdl` is empty.
-
-## Testing
-
-No test harness yet. For v0.3 add Deno's built-in test runner targeting the state stores (pure logic, no DOM):
-
-- `tests/session.test.ts` — tab add/close/rename/persist round-trip
-- `tests/storage.test.ts` — memory storage + namespace isolation
-- `tests/history.test.ts` — eviction and favorite pinning
-
-Component tests can wait until there's demand; Svelte 5 + runes + Vitest is still settling.
-
-## Architecture notes for future me
-
-The separation between **state stores** (`*.svelte.ts` classes) and **components** is deliberate. Stores own all mutation logic and can be unit tested without a DOM. Components own rendering and side effects (persistence timers, keyboard handlers).
-
-The `$effect` in `GraphiQL.svelte` intentionally reads `session.tabs` and `session.activeId` via `void` to establish dependencies without using the values. This is idiomatic runes and cleaner than `$effect.root` with explicit subscriptions.
-
-The fetcher abstraction is intentionally minimal — one function type that returns a Promise or AsyncIterable. This covers HTTP, SSE, WS, and any future transport without an interface explosion. Transport-specific options live in the factory function, not the fetcher itself.
-
-**Why CodeMirror 6 over Monaco.** Bundle size, build simplicity, and philosophical fit. Monaco is multi-megabyte with required web worker setup in Vite — a real friction point for consumers. CM6 is tens of kilobytes, composed of opt-in extensions, and works with zero build config. `cm6-graphql` is maintained inside the graphql/graphiql monorepo itself and uses the same `graphql-language-service` that powers `monaco-graphql`, so we get schema-aware autocomplete and linting without the bloat. The tradeoff is slightly less polished hover docs and no signature-help popups — acceptable for v1, addressable later via custom CM6 extensions if needed.
-
-When in doubt about API shape, look at `@graphiql/toolkit` for reference — the GraphiQL maintainers have already factored out the transport concerns nicely, we're just giving it a Svelte + CM6 skin.
diff --git a/README.md b/README.md
index c8d9132..91364d5 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ Full prop list:
| `storage` | `Storage` | `localStorage`-based |
| `subscriptionMode` | `"append" \| "replace"` | `"append"` |
| `tabExtras` | `Snippet<[{ tab: Tab }]>` | `undefined` |
-| `theme` | `Extension` (CodeMirror) | `oneDark` |
+| `theme` | `Extension` (CodeMirror) | `lightTheme` |
| `toolbarExtras` | `Snippet` | `undefined` |
## Integration
@@ -254,17 +254,7 @@ The History panel ships Export/Import buttons that round-trip through this valid
## Theming
-CSS custom properties drive the chrome:
-
-- `--graphiql-accent`
-- `--graphiql-bg`
-- `--graphiql-border`
-- `--graphiql-fg`
-- `--graphiql-font`
-- `--graphiql-muted`
-- `--graphiql-panel`
-
-The editor theme is a separate CodeMirror `Extension` passed via the `theme` prop. Ships with `oneDark` (default) and `lightTheme`:
+The editor theme is a separate CodeMirror `Extension` passed via the `theme` prop. Ships with `lightTheme` (default):
```svelte
<script lang="ts">
diff --git a/package.json b/package.json
index fe4a49e..62c07c6 100644
--- a/package.json
+++ b/package.json
@@ -1,29 +1,32 @@
{
"dependencies": {
- "@codemirror/autocomplete": "^6.18.0",
- "@codemirror/commands": "^6.7.0",
- "@codemirror/lang-json": "^6.0.1",
- "@codemirror/language": "^6.10.0",
- "@codemirror/state": "^6.4.0",
- "@codemirror/theme-one-dark": "^6.1.0",
- "@codemirror/view": "^6.34.0",
- "@lezer/highlight": "^1.2.0",
+ "@codemirror/autocomplete": "^6.20.1",
+ "@codemirror/commands": "^6.10.3",
+ "@codemirror/lang-json": "^6.0.2",
+ "@codemirror/language": "^6.12.3",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.41.1",
+ "@inc/uchu": "^2.2.0",
+ "@lezer/highlight": "^1.2.3",
"cm6-graphql": "^0.2.1",
- "codemirror": "^6.0.1",
- "graphql": "^16.8.0",
- "graphql-sse": "^2.5.3",
- "graphql-ws": "^5.16.0"
+ "codemirror": "^6.0.2",
+ "graphql": "^16.13.2",
+ "graphql-sse": "^2.6.0",
+ "graphql-ws": "^6.0.8"
},
"description": "A Svelte 5 GraphiQL alternative.",
"devDependencies": {
- "@sveltejs/package": "^2.3.0",
- "@sveltejs/vite-plugin-svelte": "^4.0.0",
- "sass-embedded": "^1.79.0",
- "svelte": "^5.0.0",
- "svelte-check": "^4.0.0",
- "typescript": "^5.5.0",
- "vite": "^5.4.0",
- "vitest": "^2.1.0"
+ "@sveltejs/package": "^2.5.7",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "sass": "npm:sass-embedded@^1.99.0",
+ "sass-embedded": "^1.99.0",
+ "svelte": "^5.55.5",
+ "svelte-check": "^4.4.6",
+ "svelte-preprocess": "^6.0.3",
+ "typescript": "^6.0.3",
+ "updates": "^17.16.3",
+ "vite": "^8.0.10",
+ "vitest": "^4.1.5"
},
"exports": {
".": {
@@ -45,7 +48,7 @@
"license": "MIT",
"name": "@eeeooolll/graphiql",
"peerDependencies": {
- "svelte": "^5.0.0"
+ "svelte": "^5.55.5"
},
"publishConfig": {
"access": "public"
@@ -54,11 +57,12 @@
"build": "vite build",
"check": "svelte-check --tsconfig tsconfig.json",
"dev": "vite",
- "package": "svelte-package -i source/library -o dist",
+ "package": "svelte-package --input source/library --output dist",
"prepublishOnly": "bun run package",
"preview": "vite preview",
"publish": "bun run package && bun publish",
- "test": "vitest run"
+ "test": "vitest run",
+ "update": "updates --update"
},
"type": "module",
"version": "0.1.0"
diff --git a/playground/index.html b/playground/index.html
index 7487569..53015ed 100644
--- a/playground/index.html
+++ b/playground/index.html
@@ -3,12 +3,32 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
- <title>@eeeooolll/graphiql · playground</title>
+
+ <title>@eeeooolll/graphiql &bull; playground</title>
+ <link rel="icon" href="./src/favicon.svg"/>
+
<style>
- html, body, #app { height: 100%; margin: 0; }
- body { background: #1e1e1e; color: #d4d4d4; font-family: ui-monospace, SFMono-Regular, monospace; }
+ *,
+ *::before,
+ *::after {
+ margin: 0; padding: 0;
+ box-sizing: inherit;
+ }
+
+ html {
+ width: 100%; height: 100%;
+
+ box-sizing: border-box;
+ overscroll-behavior: none;
+ }
+
+ body,
+ #app {
+ width: inherit; height: inherit;
+ }
</style>
</head>
+
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
diff --git a/playground/src/App.svelte b/playground/src/App.svelte
index dc4f0a5..350f956 100644
--- a/playground/src/App.svelte
+++ b/playground/src/App.svelte
@@ -1,24 +1,33 @@
<script lang="ts">
/*** IMPORT ------------------------------------------- ***/
- import { createHttpFetcher } from "../../source/library/index.ts";
+ import { createHttpFetcher, lightTheme } from "../../source/library/index.ts";
import GraphiQL from "../../source/library/GraphiQL.svelte";
/*** UTILITY ------------------------------------------ ***/
const fetcher = createHttpFetcher({
url: "https://graphql.pokeapi.co/v1beta2"
- // url: "https://countries.trevorblades.com/"
});
- const initialQuery = `query Countries {
- countries {
- code
- name
- emoji
- }
-}
-`;
+ const initialQuery = `
+ query best_grass_poison_pokemons {
+ pokemon: pokemon(
+ where: {_and: [{pokemontypes: {type: {name: {_eq: "grass"}}}}, {pokemontypes: {type: {name: {_eq: "poison"}}}}]}
+ order_by: {pokemonstats_aggregate: {sum: {base_stat: desc}}}
+ limit: 3
+ ) {
+ name
+ stats: pokemonstats_aggregate(order_by: {}) {
+ aggregate {
+ sum {
+ base_stat
+ }
+ }
+ }
+ }
+ }
+ `;
</script>
-<GraphiQL {fetcher} {initialQuery}/>
+<GraphiQL {fetcher} {initialQuery} theme={lightTheme}/>
diff --git a/playground/src/favicon.svg b/playground/src/favicon.svg
new file mode 100644
index 0000000..80a7a21
--- /dev/null
+++ b/playground/src/favicon.svg
@@ -0,0 +1,15 @@
+<svg fill="#e10098" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
+ <style>
+ svg {
+ fill: color(display-p3 0.8824 0 0.5961);
+ }
+ </style>
+
+ <path clip-rule="evenodd" d="M50 6.90308L87.323 28.4515V71.5484L50 93.0968L12.677 71.5484V28.4515L50 6.90308ZM16.8647 30.8693V62.5251L44.2795 15.0414L16.8647 30.8693ZM50 13.5086L18.3975 68.2457H81.6025L50 13.5086ZM77.4148 72.4334H22.5852L50 88.2613L77.4148 72.4334ZM83.1353 62.5251L55.7205 15.0414L83.1353 30.8693V62.5251Z" fill-rule="evenodd"/>
+ <circle cx="50" cy="9.3209" r="8.82"/>
+ <circle cx="85.2292" cy="29.6605" r="8.82"/>
+ <circle cx="85.2292" cy="70.3396" r="8.82"/>
+ <circle cx="50" cy="90.6791" r="8.82"/>
+ <circle cx="14.7659" cy="70.3396" r="8.82"/>
+ <circle cx="14.7659" cy="29.6605" r="8.82"/>
+</svg>
diff --git a/playground/src/main.ts b/playground/src/main.ts
index bf39df9..03a4c37 100644
--- a/playground/src/main.ts
+++ b/playground/src/main.ts
@@ -1,8 +1,18 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
import { mount } from "svelte";
+
+/*** UTILITY ------------------------------------------ ***/
+
import App from "./App.svelte";
const target = document.getElementById("app");
+/*** RUNTIME ------------------------------------------ ***/
+
if (!target)
throw new Error("missing #app target");
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte
index 0f1e0fd..3ffc372 100644
--- a/source/library/GraphiQL.svelte
+++ b/source/library/GraphiQL.svelte
@@ -1,28 +1,28 @@
<script lang="ts">
/*** IMPORT ------------------------------------------- ***/
-
+ import "@inc/uchu/css";
import { onMount, type Snippet } from "svelte";
+ import type { Extension } from "@codemirror/state";
/*** 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 Splitter from "./components/Splitter.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 { createLocalStorage, createMemoryStorage } from "./state/storage.ts";
import { HistoryStore } from "./state/history.svelte.ts";
import { matchShortcut } from "./state/keyboard.ts";
import { SchemaStore } from "./state/schema.svelte.ts";
import { SessionStore } from "./state/session.svelte.ts";
- import type { SubscriptionMode, Tab } from "./state/session.svelte.ts";
import { validateSessionExport } from "./state/session-io.ts";
- import { createLocalStorage, createMemoryStorage } from "./state/storage.ts";
+
+ import type { Fetcher } from "./fetcher/types.ts";
import type { Storage } from "./state/storage.ts";
+ import type { SubscriptionMode, Tab } from "./state/session.svelte.ts";
type Props = {
fetcher: Fetcher;
@@ -36,6 +36,9 @@
toolbarExtras?: Snippet;
};
+ const MINIMUM_HISTORY_WIDTH = 300;
+ const PERSIST_DEBOUNCE_MS = 300;
+
let {
fetcher,
initialQuery = "",
@@ -55,38 +58,59 @@
createLocalStorage(namespace) :
createMemoryStorage());
- const PERSIST_DEBOUNCE_MS = 300;
const history = new HistoryStore(resolvedStorage);
const schema = new SchemaStore();
const session = new SessionStore(resolvedStorage);
// svelte-ignore state_referenced_locally
- if (initialQuery && session.active && session.active.query === "")
+ if (initialQuery && session.active && session.active.query === "") {
// svelte-ignore state_referenced_locally
session.updateQuery(session.active.id, initialQuery);
+ }
let bottomPane = $state<"variables" | "headers">("variables");
let bottomHeight = $state(resolvedStorage.get<number>("layout.bottomHeight") ?? 35);
let centerEl = $state<HTMLDivElement | null>(null);
let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false);
let docsWidth = $state(resolvedStorage.get<number>("layout.docsWidth") ?? 320);
+ let dragStartBottomHeight = 0;
+ let dragStartBottomHeightPx = 0;
+ let dragStartDocsWidth = 0;
+ let dragStartHistoryWidth = 0;
+ let dragStartLeftWidth = 0;
+ let dragStartLeftWidthPx = 0;
let historyNotice = $state<string | null>(null);
let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false);
- let historyWidth = $state(resolvedStorage.get<number>("layout.historyWidth") ?? 260);
+ let historyWidth = $state(resolvedStorage.get<number>("layout.historyWidth") ?? MINIMUM_HISTORY_WIDTH);
let leftEl = $state<HTMLDivElement | null>(null);
let leftWidth = $state(resolvedStorage.get<number>("layout.leftWidth") ?? 50);
+ let runAbort: AbortController | null = null;
let running = $state(false);
+ let windowWidth = $state(0);
+ let windowHeight = $state(0);
+ let windowResizeTimeout: ReturnType<typeof setTimeout>;
+
+
- let dragStartHistoryWidth = 0;
- let dragStartDocsWidth = 0;
- let dragStartLeftWidth = 0;
- let dragStartLeftWidthPx = 0;
- let dragStartBottomHeight = 0;
- let dragStartBottomHeightPx = 0;
- function clamp(value: number, min: number, max: number): number {
- return Math.min(Math.max(value, min), max);
- }
+
+ $effect(() => {
+ const updateSize = () => {
+ clearTimeout(windowResizeTimeout);
+
+ windowResizeTimeout = setTimeout(() => {
+ windowWidth = globalThis.innerWidth;
+ windowHeight = globalThis.innerHeight;
+ }, 100);
+ };
+
+ updateSize();
+ globalThis.addEventListener("resize", updateSize);
+
+ return () => {
+ globalThis.removeEventListener("resize", updateSize);
+ };
+ });
$effect(() => {
void session.tabs;
@@ -137,7 +161,10 @@
schema.introspect(fetcher);
});
- let runAbort: AbortController | null = null;
+ /*** HELPER ------------------------------------------- ***/
+ function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+ }
async function run() {
if (running) {
@@ -190,6 +217,39 @@
session.overwriteActive(seed);
}
+ function onBeforeUnload() {
+ session.persist();
+ }
+
+ function onBottomDrag(_dx: number, dy: number) {
+ if (dragStartBottomHeightPx === 0)
+ return;
+
+ const percentDelta = (dy / dragStartBottomHeightPx) * 100;
+ bottomHeight = clamp(dragStartBottomHeight - percentDelta, 15, 70);
+ }
+
+ function onBottomDragStart() {
+ dragStartBottomHeight = bottomHeight;
+ dragStartBottomHeightPx = leftEl?.getBoundingClientRect().height ?? 0;
+ }
+
+ function onBottomKeyAdjust(delta: number) {
+ bottomHeight = clamp(bottomHeight - delta, 15, 70);
+ }
+
+ function onDocsDrag(dx: number) {
+ docsWidth = clamp(dragStartDocsWidth - dx, 240, 600);
+ }
+
+ function onDocsDragStart() {
+ dragStartDocsWidth = docsWidth;
+ }
+
+ function onDocsKeyAdjust(delta: number) {
+ docsWidth = clamp(docsWidth - delta, 240, 600);
+ }
+
function onExportSession() {
if (typeof globalThis.URL === "undefined")
return;
@@ -201,12 +261,31 @@
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = globalThis.document.createElement("a");
+
a.download = `graphiql-session-${data.exportedAt}.json`;
a.href = url;
a.click();
+
URL.revokeObjectURL(url);
}
+ function onHeadersChange(value: string) {
+ if (session.active)
+ session.active.headers = value;
+ }
+
+ function onHistoryDrag(dx: number) {
+ historyWidth = clamp(dragStartHistoryWidth + dx, MINIMUM_HISTORY_WIDTH, 500);
+ }
+
+ function onHistoryDragStart() {
+ dragStartHistoryWidth = historyWidth;
+ }
+
+ function onHistoryKeyAdjust(delta: number) {
+ historyWidth = clamp(historyWidth + delta, MINIMUM_HISTORY_WIDTH, 500);
+ }
+
async function onImportSession(file: File) {
let text: string;
@@ -245,21 +324,6 @@
historyNotice = parts.join(", ");
}
- 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 action = matchShortcut(event);
@@ -269,60 +333,38 @@
event.preventDefault();
switch (action.type) {
- case "closeTab":
+ case "closeTab": {
session.closeTab(session.activeId);
break;
- case "format":
+ }
+
+ case "format": {
session.formatActive();
break;
- case "newTab":
+ }
+
+ case "newTab": {
session.addTab();
break;
- case "nextTab":
+ }
+
+ case "nextTab": {
session.nextTab();
break;
- case "prevTab":
+ }
+
+ case "prevTab": {
session.prevTab();
break;
- case "run":
+ }
+
+ case "run": {
run();
break;
+ }
}
}
- function onBeforeUnload() {
- session.persist();
- }
-
- function onHistoryDragStart() {
- dragStartHistoryWidth = historyWidth;
- }
-
- function onHistoryDrag(dx: number) {
- historyWidth = clamp(dragStartHistoryWidth + dx, 200, 500);
- }
-
- function onHistoryKeyAdjust(delta: number) {
- historyWidth = clamp(historyWidth + delta, 200, 500);
- }
-
- function onDocsDragStart() {
- dragStartDocsWidth = docsWidth;
- }
-
- function onDocsDrag(dx: number) {
- docsWidth = clamp(dragStartDocsWidth - dx, 240, 600);
- }
-
- function onDocsKeyAdjust(delta: number) {
- docsWidth = clamp(docsWidth - delta, 240, 600);
- }
-
- function onLeftDragStart() {
- dragStartLeftWidth = leftWidth;
- dragStartLeftWidthPx = centerEl?.getBoundingClientRect().width ?? 0;
- }
-
function onLeftDrag(dx: number) {
if (dragStartLeftWidthPx === 0)
return;
@@ -331,25 +373,23 @@
leftWidth = clamp(dragStartLeftWidth + percentDelta, 20, 80);
}
- function onLeftKeyAdjust(delta: number) {
- leftWidth = clamp(leftWidth + delta, 20, 80);
+ function onLeftDragStart() {
+ dragStartLeftWidth = leftWidth;
+ dragStartLeftWidthPx = centerEl?.getBoundingClientRect().width ?? 0;
}
- function onBottomDragStart() {
- dragStartBottomHeight = bottomHeight;
- dragStartBottomHeightPx = leftEl?.getBoundingClientRect().height ?? 0;
+ function onLeftKeyAdjust(delta: number) {
+ leftWidth = clamp(leftWidth + delta, 20, 80);
}
- function onBottomDrag(_dx: number, dy: number) {
- if (dragStartBottomHeightPx === 0)
- return;
-
- const percentDelta = (dy / dragStartBottomHeightPx) * 100;
- bottomHeight = clamp(dragStartBottomHeight - percentDelta, 15, 70);
+ function onQueryChange(value: string) {
+ if (session.active)
+ session.updateQuery(session.active.id, value);
}
- function onBottomKeyAdjust(delta: number) {
- bottomHeight = clamp(bottomHeight - delta, 15, 70);
+ function onVariablesChange(value: string) {
+ if (session.active)
+ session.active.variables = value;
}
</script>
@@ -357,11 +397,22 @@
.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;
+ background-color: var(--uchu-yang);
+ color: var(--uchu-yin-9);
+ font-family: "Berkeley Mono", ui-monospace, SFMono-Regular, monospace;
+ font-size: 16px;
+ overflow: hidden;
+ overscroll-behavior: none;
+
+ @media (min-width: 1025px) {
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+ }
+
+ @media (max-width: 1024px) {
+ display: flex;
+ flex-direction: column;
+ }
}
.panes {
@@ -369,6 +420,10 @@
grid-template-columns: 1fr;
min-height: 0;
+ @media (max-width: 1024px) {
+ height: stretch;
+ }
+
&.history-open {
grid-template-columns: var(--graphiql-history-width) 6px 1fr;
}
@@ -382,61 +437,91 @@
}
}
- .center {
- display: grid;
- grid-template-columns: var(--graphiql-left-width) 6px calc(100% - var(--graphiql-left-width) - 6px);
- min-height: 0;
- min-width: 0;
- }
-
+ .center,
.left {
- display: grid;
- grid-template-rows: 1fr auto 6px var(--graphiql-bottom-height);
+ margin-right: -6px;
min-height: 0;
min-width: 0;
}
- .query {
- min-height: 0;
- }
+ .center {
+ @media (min-width: 1025px) {
+ display: grid;
+ grid-template-columns: var(--graphiql-left-width) 6px calc(100% - var(--graphiql-left-width) - 6px);
+ }
- .switcher {
- background-color: var(--graphiql-panel, #252526);
- border-top: 1px solid var(--graphiql-border, #333);
- display: flex;
- }
+ @media (max-width: 1024px) {
+ display: flex;
+ flex-direction: column;
+ }
- .switch {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 0.75rem;
- letter-spacing: 0.05em;
- padding: 0.375rem 0.75rem;
- text-transform: uppercase;
+ .query {
+ flex: 2;
+ margin-bottom: -6px;
+ min-height: 0;
+ }
- &:not(.active) {
- color: var(--graphiql-muted, #858585);
+ .bottom {
+ flex: 1;
+ min-height: 0;
}
- &.active {
- color: var(--graphiql-fg, #d4d4d4);
+ .left {
+ display: flex;
+ flex-direction: column;
+ overflow-x: hidden;
+
+ @media (max-width: 1024px) {
+ flex: 2;
+ }
}
- }
- .bottom {
- min-height: 0;
+ .right {
+ min-height: 0;
+
+ @media (max-width: 1024px) {
+ border-top: 2px solid var(--uchu-gray-3);
+ flex: 1;
+ }
+ }
}
- .right {
- min-height: 0;
+ .switcher {
+ background-color: var(--uchu-gray-2);
+ display: flex;
+ flex-direction: row;
+
+ .switch {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-family: "Berkeley Mono", ui-monospace, SFMono-Regular, monospace;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.05rem;
+ padding: 0.375rem 0.75rem;
+ text-transform: uppercase;
+
+ &:not(.active) {
+ color: var(--uchu-yin-5);
+ }
+
+ &.active {
+ background-color: var(--uchu-gray-3);
+ }
+ }
}
.status {
- background-color: var(--graphiql-panel, #252526);
- border-top: 1px solid var(--graphiql-border, #333);
+ background-color: var(--uchu-yellow-2);
+ border-top: 1px solid var(--uchu-yellow-3);
+ color: var(--uchu-yellow-9);
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
+
+ span {
+ text-transform: uppercase;
+ }
}
</style>
@@ -473,8 +558,7 @@
class="panes"
class:docs-open={docsOpen && schema.schema}
class:history-open={historyOpen}
- style="--graphiql-history-width: {historyWidth}px; --graphiql-docs-width: {docsWidth}px;"
- >
+ style="--graphiql-history-width: {historyWidth}px; --graphiql-docs-width: {docsWidth}px;">
{#if historyOpen}
<HistoryPanel
entries={history.entries}
@@ -492,11 +576,11 @@
onKeyAdjust={onHistoryKeyAdjust}
orientation="horizontal"/>
{/if}
+
<div
bind:this={centerEl}
class="center"
- style="--graphiql-left-width: {leftWidth}%;"
- >
+ style="--graphiql-left-width: {leftWidth}%;">
<div bind:this={leftEl} class="left" style="--graphiql-bottom-height: {bottomHeight}%;">
<div class="query">
<Editor
@@ -506,22 +590,25 @@
{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>
+
<Splitter
onDrag={onBottomDrag}
onDragStart={onBottomDragStart}
onKeyAdjust={onBottomKeyAdjust}
orientation="vertical"/>
+
<div class="bottom">
+ <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>
+
{#if bottomPane === "variables"}
<Editor
language="json"
@@ -529,18 +616,23 @@
{theme}
value={session.active?.variables ?? "{}"}/>
{:else}
- <HeadersEditor
+ <Editor
+ language="json"
onChange={onHeadersChange}
{theme}
value={session.active?.headers ?? "{}"}/>
{/if}
</div>
</div>
- <Splitter
- onDrag={onLeftDrag}
- onDragStart={onLeftDragStart}
- onKeyAdjust={onLeftKeyAdjust}
- orientation="horizontal"/>
+
+ {#if windowWidth >= 1025}
+ <Splitter
+ onDrag={onLeftDrag}
+ onDragStart={onLeftDragStart}
+ onKeyAdjust={onLeftKeyAdjust}
+ orientation="horizontal"/>
+ {/if}
+
<div class="right">
<ResultViewer
footer={resultFooter}
@@ -550,6 +642,7 @@
value={session.active?.result ?? ""}/>
</div>
</div>
+
{#if docsOpen && schema.schema}
<Splitter
onDrag={onDocsDrag}
@@ -559,7 +652,8 @@
<DocExplorer schema={schema.schema}/>
{/if}
</div>
+
{#if schema.error}
- <div class="status">Schema error: {schema.error}</div>
+ <div class="status"><span>Schema error</span> {schema.error}</div>
{/if}
</div>
diff --git a/source/library/components/DocExplorer.svelte b/source/library/components/DocExplorer.svelte
index 536cb2a..48525e1 100644
--- a/source/library/components/DocExplorer.svelte
+++ b/source/library/components/DocExplorer.svelte
@@ -1,18 +1,19 @@
<script lang="ts">
- import FieldView from "./DocExplorer/FieldView.svelte";
- import TypeView from "./DocExplorer/TypeView.svelte";
+ /*** IMPORT ------------------------------------------- ***/
import {
isInputObjectType,
isInterfaceType,
- isObjectType
- } from "graphql";
- import type {
- GraphQLField,
- GraphQLInputField,
- GraphQLNamedType,
- GraphQLSchema
+ isObjectType,
+ type GraphQLField,
+ type GraphQLInputField,
+ type GraphQLNamedType,
+ type GraphQLSchema
} from "graphql";
+ /*** UTILITY ------------------------------------------ ***/
+ import FieldView from "./DocExplorer/FieldView.svelte";
+ import TypeView from "./DocExplorer/TypeView.svelte";
+
type NavEntry =
| { kind: "field"; fieldName: string; typeName: string }
| { kind: "type"; name: string };
@@ -22,7 +23,6 @@
};
let { schema }: Props = $props();
-
let stack = $state<NavEntry[]>([]);
const current = $derived<NavEntry | null>(stack.length > 0 ? stack[stack.length - 1] : null);
@@ -63,23 +63,17 @@
return null;
});
+ /*** HELPER ------------------------------------------- ***/
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 gotoRoot() {
+ stack = [];
}
function pushField(fieldName: string) {
@@ -90,54 +84,62 @@
stack = [...stack, { fieldName, kind: "field", typeName }];
}
+
+ function pushType(name: string) {
+ if (!schema.getType(name))
+ return;
+
+ stack = [...stack, { kind: "type", name }];
+ }
</script>
<style lang="scss">
.explorer {
- background: var(--graphiql-panel, #252526);
- border-left: 1px solid var(--graphiql-border, #333);
+ background-color: color-mix(in oklch shorter hue, var(--uchu-gray-1) 20%, var(--uchu-yang) 80%);
+ border-left: 1px solid var(--uchu-gray-2);
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
overflow: hidden;
+ z-index: 1;
}
.breadcrumbs {
align-items: center;
- border-bottom: 1px solid var(--graphiql-border, #333);
+ border-bottom: 1px solid var(--uchu-gray-2);
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;
- }
+ .crumb {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:not(.current) {
+ color: var(--uchu-blue-3);
+ text-decoration: underline;
+ }
- &.current {
- color: var(--graphiql-fg, #d4d4d4);
- cursor: default;
+ &.current {
+ color: var(--uchu-yin-4);
+ cursor: default;
- &:hover {
- text-decoration: none;
+ &:hover {
+ text-decoration: none;
+ }
}
}
}
.separator {
- color: var(--graphiql-muted, #858585);
+ color: var(--uchu-gray-3);
}
.body {
@@ -149,39 +151,39 @@
display: grid;
gap: 0.75rem;
padding: 0.75rem 1rem;
+
+ &-list {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ &-link {
+ background: none;
+ border: none;
+ color: var(--uchu-blue-3);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.95rem;
+ padding: 0;
+ text-align: left;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
}
.section-label {
- color: var(--graphiql-muted, #858585);
+ color: var(--uchu-yin-6);
font-size: 0.7rem;
- letter-spacing: 0.05em;
+ letter-spacing: 0.05rem;
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;
+ color: var(--uchu-yin-6);
+ font-size: 0.8rem;
padding: 0.75rem 1rem;
}
</style>
@@ -200,10 +202,12 @@
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)}>
diff --git a/source/library/components/DocExplorer/FieldView.svelte b/source/library/components/DocExplorer/FieldView.svelte
index 71d215c..1cc62ae 100644
--- a/source/library/components/DocExplorer/FieldView.svelte
+++ b/source/library/components/DocExplorer/FieldView.svelte
@@ -1,7 +1,11 @@
<script lang="ts">
- import TypeLink from "./TypeLink.svelte";
+ /*** IMPORT ------------------------------------------- ***/
import type { GraphQLField, GraphQLInputField } from "graphql";
+ /*** UTILITY ------------------------------------------ ***/
+ import { markdown } from "../../graphql/markdown.ts";
+ import TypeLink from "./TypeLink.svelte";
+
type Props = {
field: GraphQLField<unknown, unknown> | GraphQLInputField;
onNavigate: (typeName: string) => void;
@@ -15,7 +19,7 @@
<style lang="scss">
.field {
display: grid;
- gap: 0.75rem;
+ gap: 1.5rem;
padding: 0.75rem 1rem;
}
@@ -24,60 +28,62 @@
font-weight: 600;
}
+ .section-label,
+ .description,
+ .arg-description {
+ color: var(--uchu-yin-6);
+ }
+
.section-label {
- color: var(--graphiql-muted, #858585);
font-size: 0.7rem;
- letter-spacing: 0.05em;
+ letter-spacing: 0.05rem;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.description {
- color: var(--graphiql-muted, #858585);
- font-size: 0.8125rem;
+ font-size: 0.8rem;
line-height: 1.4;
}
.args {
display: grid;
- gap: 0.375rem;
+ gap: 1.5rem;
}
.arg {
- font-size: 0.8125rem;
- }
+ font-size: 0.8rem;
- .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;
+ &-description {
+ font-size: 0.7rem;
+ 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>
+ <div class="description">{@html markdown(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>
+ <div class="arg-description">{@html markdown(arg.description)}</div>
{/if}
</div>
{/each}
diff --git a/source/library/components/DocExplorer/TypeLink.svelte b/source/library/components/DocExplorer/TypeLink.svelte
index 253d16e..03f5c1e 100644
--- a/source/library/components/DocExplorer/TypeLink.svelte
+++ b/source/library/components/DocExplorer/TypeLink.svelte
@@ -1,7 +1,13 @@
<script lang="ts">
- import { getNamedType, isListType, isNonNullType } from "graphql";
- import type { GraphQLType } from "graphql";
-
+ /*** IMPORT ------------------------------------------- ***/
+ import {
+ getNamedType,
+ isListType,
+ isNonNullType,
+ type GraphQLType
+ } from "graphql";
+
+ /*** UTILITY ------------------------------------------ ***/
type Props = {
onNavigate: (typeName: string) => void;
type: GraphQLType;
@@ -27,13 +33,13 @@
.link {
background: none;
border: none;
- color: var(--graphiql-link, #79b8ff);
+ color: var(--uchu-blue-3);
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0;
- &:hover {
+ &:not(:hover) {
text-decoration: underline;
}
}
diff --git a/source/library/components/DocExplorer/TypeView.svelte b/source/library/components/DocExplorer/TypeView.svelte
index 31a1ca3..c1978d6 100644
--- a/source/library/components/DocExplorer/TypeView.svelte
+++ b/source/library/components/DocExplorer/TypeView.svelte
@@ -1,14 +1,18 @@
<script lang="ts">
- import TypeLink from "./TypeLink.svelte";
+ /*** IMPORT ------------------------------------------- ***/
import {
isEnumType,
isInputObjectType,
isInterfaceType,
isObjectType,
isScalarType,
- isUnionType
+ isUnionType,
+ type GraphQLNamedType
} from "graphql";
- import type { GraphQLNamedType } from "graphql";
+
+ /*** UTILITY ------------------------------------------ ***/
+ import { markdown } from "../../graphql/markdown.ts";
+ import TypeLink from "./TypeLink.svelte";
type Props = {
onNavigateField: (fieldName: string) => void;
@@ -72,7 +76,7 @@
<style lang="scss">
.type {
display: grid;
- gap: 0.75rem;
+ gap: 1.5rem;
padding: 0.75rem 1rem;
}
@@ -81,67 +85,80 @@
font-weight: 600;
}
+ .kind,
+ .description,
+ .entry-description {
+ color: var(--uchu-yin-6);
+ }
+
.kind {
- color: var(--graphiql-muted, #858585);
font-weight: normal;
margin-right: 0.375rem;
}
.description {
- color: var(--graphiql-muted, #858585);
- font-size: 0.8125rem;
+ font-size: 0.8rem;
line-height: 1.4;
+
+ :global(a) {
+ background-color: oklch(var(--uchu-blue-1-raw) / 25%);
+ color: var(--uchu-blue-4);
+ }
+
+ :global(code) {
+ background-color: var(--uchu-yellow-1);
+ }
}
.section-label {
- color: var(--graphiql-muted, #858585);
font-size: 0.7rem;
- letter-spacing: 0.05em;
+ letter-spacing: 0.05rem;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.list {
display: grid;
- gap: 0.375rem;
+ gap: 1.5rem;
}
.entry {
- font-size: 0.8125rem;
+ font-size: 0.8rem;
+
+ &-description {
+ font-size: 0.7rem;
+ margin-left: 1rem;
+ margin-top: 0.125rem;
+ }
}
.field-button {
background: none;
border: none;
- color: var(--graphiql-fg, #d4d4d4);
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0;
- &:hover {
+ &:not(: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>
+ <div class="description">{@html markdown(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">
@@ -151,9 +168,11 @@
</div>
</div>
{/if}
+
{#if fields.length > 0}
<div>
<div class="section-label">Fields</div>
+
<div class="list">
{#each fields as field}
<div class="entry">
@@ -161,17 +180,20 @@
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>
+ <div class="entry-description">{@html markdown(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">
@@ -181,15 +203,18 @@
</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>
+ <div class="entry-description">{@html markdown(value.description)}</div>
{/if}
</div>
{/each}
diff --git a/source/library/components/Editor.svelte b/source/library/components/Editor.svelte
index 073a76a..77ef5ea 100644
--- a/source/library/components/Editor.svelte
+++ b/source/library/components/Editor.svelte
@@ -1,12 +1,12 @@
<script lang="ts">
/*** IMPORT ------------------------------------------- ***/
-
import { onMount } from "svelte";
- import type { Extension } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
+ import type { Extension } from "@codemirror/state";
import type { GraphQLSchema } from "graphql";
/*** UTILITY ------------------------------------------ ***/
+ import { lightTheme } from "../themes/light.ts";
type Props = {
language?: "graphql" | "json";
@@ -61,7 +61,7 @@
if (disposed)
return;
- const themeExt: Extension = theme ?? (await import("@codemirror/theme-one-dark")).oneDark;
+ const themeExt: Extension = theme ?? lightTheme;
const languageExt = language === "graphql" ?
graphql(schema ? buildSchema(schema) : undefined) :
@@ -131,8 +131,7 @@
<style lang="scss">
.editor {
- height: 100%;
- width: 100%;
+ width: 100%; height: 100%;
:global(.cm-editor) {
height: 100%;
diff --git a/source/library/components/HeadersEditor.svelte b/source/library/components/HeadersEditor.svelte
deleted file mode 100644
index fc3a193..0000000
--- a/source/library/components/HeadersEditor.svelte
+++ /dev/null
@@ -1,34 +0,0 @@
-<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
index 01f397a..e224e1e 100644
--- a/source/library/components/HistoryPanel.svelte
+++ b/source/library/components/HistoryPanel.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ /*** UTILITY ------------------------------------------ ***/
import type { HistoryEntry } from "../state/history.svelte.ts";
type Props = {
@@ -34,6 +35,7 @@
return b.timestamp - a.timestamp;
}));
+ /*** HELPER ------------------------------------------- ***/
function formatTimestamp(ms: number): string {
const d = new Date(ms);
return d.toLocaleString();
@@ -50,10 +52,6 @@
}
}
- function onImportClick() {
- fileInput?.click();
- }
-
function onFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
@@ -63,22 +61,29 @@
input.value = "";
}
+
+ function onImportClick() {
+ fileInput?.click();
+ }
</script>
<style lang="scss">
.panel {
- background: var(--graphiql-panel, #252526);
- border-right: 1px solid var(--graphiql-border, #333);
+ background-color: var(--uchu-yin-9);
+ color: var(--uchu-yin-1);
display: grid;
+ font-size: 0.8rem;
grid-template-rows: auto 1fr;
- height: 100%;
min-height: 0;
+ height: 100%;
overflow: hidden;
+ z-index: 2;
}
- .header {
+ .header,
+ .notice {
align-items: center;
- border-bottom: 1px solid var(--graphiql-border, #333);
+ border-bottom: 1px solid var(--uchu-yin-8);
display: flex;
gap: 0.5rem;
justify-content: space-between;
@@ -86,26 +91,32 @@
}
.title {
- font-size: 0.8125rem;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.5rem;
+
+ .action {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.75rem;
+ padding: 0;
+ }
}
- .action {
- background: none;
- border: none;
- color: var(--graphiql-muted, #858585);
- cursor: pointer;
- font-family: inherit;
- font-size: 0.75rem;
- padding: 0;
+ .action,
+ .notice-dismiss,
+ .remove {
+ &:not(:hover) {
+ color: var(--uchu-yin-5);
+ }
&:hover {
- color: var(--graphiql-fg, #d4d4d4);
+ color: var(--uchu-yin-1);
}
}
@@ -113,38 +124,20 @@
display: none;
}
- .notice {
- align-items: center;
- background: var(--graphiql-bg, #1e1e1e);
- border-bottom: 1px solid var(--graphiql-border, #333);
- color: var(--graphiql-fg, #d4d4d4);
- display: flex;
- font-size: 0.75rem;
- gap: 0.5rem;
- justify-content: space-between;
- padding: 0.375rem 0.75rem;
- }
-
- .notice-dismiss {
+ .notice-dismiss,
+ .remove {
background: none;
border: none;
- color: var(--graphiql-muted, #858585);
cursor: pointer;
- font-size: 0.875rem;
+ font-size: 1rem;
line-height: 1;
padding: 0 0.25rem;
-
- &:hover {
- color: var(--graphiql-fg, #d4d4d4);
- }
}
.list {
display: grid;
- gap: 0.125rem;
min-height: 0;
overflow-y: auto;
- padding: 0.375rem 0;
}
.entry {
@@ -154,22 +147,56 @@
gap: 0.125rem;
grid-template-columns: auto 1fr auto;
padding: 0.375rem 0.75rem;
+ transition: opacity 0.1s;
+
+ &:not(:last-of-type) {
+ border-bottom: 1px solid var(--uchu-yin);
+ }
&:hover {
- background: var(--graphiql-bg, #1e1e1e);
+ background-color: var(--uchu-yin);
+ }
+
+ .list:has(.entry:hover) &:not(:hover) {
+ opacity: 0.4;
+ }
+
+ &-title {
+ font-size: 0.8rem;
+ margin-bottom: 0.1rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &-time {
+ color: var(--uchu-yin-5);
+ font-size: 0.7rem;
}
}
.star {
+ align-self: center;
background: none;
border: none;
- color: var(--graphiql-muted, #858585);
cursor: pointer;
- font-size: 0.875rem;
+ font-size: 0.8rem;
padding: 0 0.375rem 0 0;
+ &:not(.active) {
+ color: var(--uchu-yin-5);
+ }
+
&.active {
- color: var(--graphiql-accent, #e3b341);
+ color: var(--uchu-orange-4);
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ svg {
+ width: 1rem;
}
}
@@ -178,75 +205,55 @@
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;
+ color: var(--uchu-yin-5);
+ font-size: 0.8rem;
padding: 0.75rem;
}
</style>
<div class="panel">
- <div>
- <div class="header">
- <span class="title">History</span>
- <div class="actions">
- {#if onExport}
- <button class="action" onclick={onExport} type="button">Export</button>
- {/if}
- {#if onImport}
- <button class="action" onclick={onImportClick} type="button">Import</button>
- <input
- accept="application/json"
- bind:this={fileInput}
- class="hidden-input"
- onchange={onFileChange}
- type="file"/>
- {/if}
- {#if entries.length > 0}
- <button class="action" onclick={onClear} type="button">Clear</button>
- {/if}
- </div>
+ <div class="header">
+ <span class="title">History</span>
+
+ <div class="actions">
+ {#if onExport}
+ <button class="action" onclick={onExport} type="button">Export</button>
+ {/if}
+
+ {#if onImport}
+ <button class="action" onclick={onImportClick} type="button">Import</button>
+ <input
+ accept="application/json"
+ bind:this={fileInput}
+ class="hidden-input"
+ onchange={onFileChange}
+ type="file"/>
+ {/if}
+
+ {#if entries.length > 0}
+ <button class="action" onclick={onClear} type="button">Clear</button>
+ {/if}
</div>
- {#if notice}
- <div class="notice">
- <span>{notice}</span>
- {#if onDismissNotice}
- <button
- aria-label="Dismiss notice"
- class="notice-dismiss"
- onclick={onDismissNotice}
- type="button">×</button>
- {/if}
- </div>
- {/if}
</div>
+
+ {#if notice}
+ <div class="notice">
+ <span>{notice}</span>
+ {#if onDismissNotice}
+ <button
+ aria-label="Dismiss notice"
+ class="notice-dismiss"
+ onclick={onDismissNotice}
+ type="button">&times;</button>
+ {/if}
+ </div>
+ {/if}
+
<div class="list">
{#if sorted.length === 0}
<div class="empty">No history yet.</div>
@@ -264,16 +271,28 @@
class="star"
class:active={entry.favorite}
onclick={(e) => { e.stopPropagation(); onFavorite(entry.id); }}
- type="button">{entry.favorite ? "★" : "☆"}</button>
+ type="button">
+ {#if entry.favorite}
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 2L14.9389 7.95492L21.5106 8.90983L16.7553 13.5451L17.8779 20.0902L12 17L6.12215 20.0902L7.24472 13.5451L2.48944 8.90983L9.06108 7.95492L12 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+ </svg>
+ {:else}
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 2L14.9389 7.95492L21.5106 8.90983L16.7553 13.5451L17.8779 20.0902L12 17L6.12215 20.0902L7.24472 13.5451L2.48944 8.90983L9.06108 7.95492L12 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+ </svg>
+ {/if}
+ </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>
+ type="button">&times;</button>
</div>
{/each}
{/if}
diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte
index d277ec8..270965b 100644
--- a/source/library/components/ResultViewer.svelte
+++ b/source/library/components/ResultViewer.svelte
@@ -1,7 +1,10 @@
<script lang="ts">
- import Editor from "./Editor.svelte";
+ /*** IMPORT ------------------------------------------- ***/
import type { Extension } from "@codemirror/state";
import type { Snippet } from "svelte";
+
+ /*** UTILITY ------------------------------------------ ***/
+ import Editor from "./Editor.svelte";
import type { TabTiming } from "../state/session.svelte.ts";
type Props = {
@@ -20,18 +23,6 @@
value
}: Props = $props();
- function median(values: number[]): number {
- if (values.length === 0)
- return 0;
-
- const sorted = [...values].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
-
- return sorted.length % 2 === 0 ?
- (sorted[mid - 1] + sorted[mid]) / 2 :
- sorted[mid];
- }
-
let metadata = $derived.by(() => {
if (!timing)
return null;
@@ -39,55 +30,101 @@
if (streamIntervals.length > 0) {
const medianMs = Math.round(median(streamIntervals));
const messages = streamIntervals.length + 1;
- return `→ ${messages} messages · median ${medianMs}ms`;
+ return `${messages} messages &middot; median ${medianMs}ms`;
}
const totalMs = Math.round(timing.endMs - timing.startMs);
const firstByteMs = Math.round(timing.firstByteMs - timing.startMs);
- return `→ ${totalMs}ms · first byte ${firstByteMs}ms`;
+ return `${totalMs}ms &middot; first byte ${firstByteMs}ms`;
});
+ /*** HELPER ------------------------------------------- ***/
+ function median(values: number[]): number {
+ if (values.length === 0)
+ return 0;
+
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+
+ return sorted.length % 2 === 0 ?
+ (sorted[mid - 1] + sorted[mid]) / 2 :
+ sorted[mid];
+ }
+
function noop(_v: string) {}
</script>
<style lang="scss">
.result {
- display: grid;
- grid-template-rows: auto 1fr auto auto;
+ display: flex;
+ flex-direction: column;
height: 100%;
min-height: 0;
+ position: relative;
}
- .label {
- background: var(--graphiql-panel, #252526);
+ .label,
+ .metadata,
+ .footer {
font-size: 0.75rem;
- letter-spacing: 0.05em;
padding: 0.25rem 0.75rem;
+ }
+
+ .label,
+ .metadata {
+ background-color: var(--uchu-gray-2);
+ color: var(--uchu-yin-7);
+ }
+
+ .label {
+ top: 1rem; right: 1rem;
+
+ border: 1px solid var(--uchu-gray-3);
+ font-weight: 600;
+ letter-spacing: 0.05rem;
+ pointer-events: none;
+ position: absolute;
text-transform: uppercase;
+ z-index: 1;
+ }
+
+ .metadata,
+ .footer {
+ position: relative;
}
.metadata {
- background: var(--graphiql-panel, #252526);
- border-top: 1px solid var(--graphiql-border, #333);
- color: var(--graphiql-muted, #858585);
- font-size: 0.75rem;
- padding: 0.25rem 0.75rem;
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+
+ svg {
+ width: 1rem; height: 1rem;
+ margin-right: 0.25rem;
+ }
}
.footer {
- background: var(--graphiql-panel, #252526);
- border-top: 1px solid var(--graphiql-border, #333);
- font-size: 0.75rem;
- padding: 0.25rem 0.75rem;
+ background-color: var(--uchu-gray-3);
+ border-top: 1px solid var(--uchu-gray-4);
}
</style>
<div class="result">
<div class="label">Response</div>
+
<Editor language="json" onChange={noop} readOnly {theme} {value}/>
+
{#if metadata}
- <div class="metadata">{metadata}</div>
+ <div class="metadata">
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M14 5.75L20.25 12L14 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/>
+ <path d="M19.5 12H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/>
+ </svg>
+ {@html metadata}
+ </div>
{/if}
+
{#if footer}
<div class="footer">{@render footer({ result: value })}</div>
{/if}
diff --git a/source/library/components/Splitter.svelte b/source/library/components/Splitter.svelte
index 73d0e10..a758385 100644
--- a/source/library/components/Splitter.svelte
+++ b/source/library/components/Splitter.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ /*** UTILITY ------------------------------------------ ***/
type Props = {
label?: string;
max?: number;
@@ -27,33 +28,7 @@
let startX = $state(0);
let startY = $state(0);
- function onPointerDown(event: PointerEvent) {
- event.preventDefault();
- dragging = true;
- startX = event.clientX;
- startY = event.clientY;
- event.currentTarget instanceof Element &&
- event.currentTarget.setPointerCapture(event.pointerId);
- onDragStart?.();
- }
-
- function onPointerMove(event: PointerEvent) {
- if (!dragging)
- return;
-
- onDrag(event.clientX - startX, event.clientY - startY);
- }
-
- function onPointerUp(event: PointerEvent) {
- if (!dragging)
- return;
-
- dragging = false;
- event.currentTarget instanceof Element &&
- event.currentTarget.releasePointerCapture(event.pointerId);
- onDragEnd?.();
- }
-
+ /*** HELPER ------------------------------------------- ***/
function onKeydown(event: KeyboardEvent) {
if (!onKeyAdjust)
return;
@@ -76,49 +51,82 @@
}
}
}
+
+ function onPointerDown(event: PointerEvent) {
+ event.preventDefault();
+ dragging = true;
+ startX = event.clientX;
+ startY = event.clientY;
+ event.currentTarget instanceof Element && event.currentTarget.setPointerCapture(event.pointerId);
+ onDragStart?.();
+ }
+
+ function onPointerMove(event: PointerEvent) {
+ if (!dragging)
+ return;
+
+ onDrag(event.clientX - startX, event.clientY - startY);
+ }
+
+ function onPointerUp(event: PointerEvent) {
+ if (!dragging)
+ return;
+
+ dragging = false;
+ event.currentTarget instanceof Element && event.currentTarget.releasePointerCapture(event.pointerId);
+ onDragEnd?.();
+ }
</script>
<style lang="scss">
.splitter {
align-items: center;
- background: transparent;
display: flex;
justify-content: center;
position: relative;
touch-action: none;
user-select: none;
- &:hover::after,
- &.dragging::after {
- background: var(--graphiql-accent, #0e639c);
- }
-
&::after {
- background: var(--graphiql-border, #333);
+ border-radius: 25rem;
content: "";
position: absolute;
transition: background 120ms ease;
}
+ &:not(:hover):not(.dragging) {
+ background-color: transparent;
+
+ &::after {
+ background-color: inherit;
+ }
+ }
+
+ &:hover,
+ &.dragging {
+ background-color: oklch(var(--uchu-gray-2-raw) / 25%);
+
+ &::after,
+ &::after {
+ background-color: var(--uchu-orange-4);
+ }
+ }
+
&.horizontal {
+ width: 6px; height: 100%;
cursor: col-resize;
- height: 100%;
- width: 6px;
&::after {
- height: 100%;
- width: 1px;
+ width: 3px; height: 60%;
}
}
&.vertical {
+ width: 100%; height: 6px;
cursor: row-resize;
- height: 6px;
- width: 100%;
&::after {
- height: 1px;
- width: 100%;
+ width: 60%; height: 3px;
}
}
}
@@ -139,5 +147,4 @@
onpointermove={onPointerMove}
onpointerup={onPointerUp}
role="separator"
- tabindex="0"
-></div>
+ tabindex="0"></div>
diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte
index 0bfec02..547fa44 100644
--- a/source/library/components/TabBar.svelte
+++ b/source/library/components/TabBar.svelte
@@ -1,5 +1,8 @@
<script lang="ts">
+ /*** IMPORT ------------------------------------------- ***/
import { tick, type Snippet } from "svelte";
+
+ /*** UTILITY ------------------------------------------ ***/
import type { Tab } from "../state/session.svelte.ts";
type Props = {
@@ -12,29 +15,38 @@
tabs: Tab[];
};
- let { activeId, extras, onAdd, onClose, onRename, onSelect, tabs }: Props = $props();
+ let {
+ activeId,
+ extras,
+ onAdd,
+ onClose,
+ onRename,
+ onSelect,
+ tabs
+ }: Props = $props();
- let editingId = $state<string | null>(null);
let draft = $state<string>("");
+ let editingId = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
- async function startEditing(tab: Tab) {
- editingId = tab.id;
- draft = tab.title;
- await tick();
- inputEl?.select();
+ /*** HELPER ------------------------------------------- ***/
+ function cancel() {
+ editingId = null;
+ draft = "";
}
function commit() {
- if (editingId === null) return;
+ if (editingId === null)
+ return;
+
onRename(editingId, draft);
editingId = null;
draft = "";
}
- function cancel() {
- editingId = null;
- draft = "";
+ function handleClose(event: MouseEvent, id: string) {
+ event.stopPropagation();
+ onClose(id);
}
function onKeydown(event: KeyboardEvent) {
@@ -47,41 +59,47 @@
}
}
- function handleClose(event: MouseEvent, id: string) {
- event.stopPropagation();
- onClose(id);
+ async function startEditing(tab: Tab) {
+ editingId = tab.id;
+ draft = tab.title;
+ await tick();
+ inputEl?.select();
}
</script>
<style lang="scss">
.tabbar {
align-items: stretch;
- background: var(--graphiql-panel, #252526);
- border-bottom: 1px solid var(--graphiql-border, #333);
+ background-color: var(--uchu-gray-2);
display: flex;
- font-size: 0.8125rem;
+ font-size: 0.8rem;
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);
+ .tab {
+ align-items: center;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ gap: 0.5rem;
+ padding: 0 0.75rem;
+
+ &:not(:hover):not(.active) {
+ color: var(--uchu-yin-5);
+ }
+
+ &:hover:not(.active) {
+ color: var(--uchu-yin-8);
+ }
+
+ &:not(.active) {
+ background-color: transparent;
+ }
+
+ &.active {
+ background-color: var(--uchu-gray-3);
+ color: var(--uchu-yin-8);
+ }
}
}
@@ -90,10 +108,9 @@
}
.edit {
- background: var(--graphiql-bg, #1e1e1e);
- border: 1px solid var(--graphiql-accent, #0e639c);
- border-radius: 2px;
- color: var(--graphiql-fg, #d4d4d4);
+ background-color: var(--uchu-orange-5);
+ border: 1px solid var(--uchu-orange-5);
+ color: var(--uchu-yang);
font-family: inherit;
font-size: inherit;
min-width: 6rem;
@@ -108,9 +125,12 @@
cursor: pointer;
font-size: 1rem;
line-height: 1;
- opacity: 0.6;
padding: 0;
+ &:not(:hover) {
+ opacity: 0.6;
+ }
+
&:hover {
opacity: 1;
}
@@ -119,13 +139,17 @@
.add {
background: none;
border: none;
- color: var(--graphiql-muted, #858585);
+ border-left: 1px solid var(--uchu-gray-3);
cursor: pointer;
font-size: 1rem;
padding: 0 0.75rem;
+ &:not(:hover) {
+ color: var(--uchu-yin-5);
+ }
+
&:hover {
- color: var(--graphiql-fg, #d4d4d4);
+ color: var(--uchu-yin-8);
}
}
</style>
@@ -145,8 +169,7 @@
}
}}
role="tab"
- tabindex="0"
- >
+ tabindex="0">
{#if editingId === tab.id}
<input
bind:this={inputEl}
@@ -155,18 +178,19 @@
onblur={commit}
onclick={(e) => e.stopPropagation()}
onkeydown={onKeydown}
- type="text"
- />
+ type="text"/>
{:else}
<span class="title">{tab.title}</span>
{/if}
+
{#if extras}{@render extras({ tab })}{/if}
+
<button
aria-label="Close tab"
class="close"
- onclick={(e) => handleClose(e, tab.id)}
- >×</button>
+ onclick={(e) => handleClose(e, tab.id)}>&times;</button>
</div>
{/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
index 9882b7d..37cc7ce 100644
--- a/source/library/components/Toolbar.svelte
+++ b/source/library/components/Toolbar.svelte
@@ -1,7 +1,10 @@
<script lang="ts">
- import type { OperationInfo } from "../graphql/operations.ts";
+ /*** IMPORT ------------------------------------------- ***/
import type { Snippet } from "svelte";
+ /*** UTILITY ------------------------------------------ ***/
+ import type { OperationInfo } from "../graphql/operations.ts";
+
type Props = {
disabled: boolean;
docsAvailable?: boolean;
@@ -38,6 +41,7 @@
const namedOperations = $derived(operations.filter((o) => o.name !== null));
+ /*** HELPER ------------------------------------------- ***/
function onPick(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
onSelectOperation?.(value || null);
@@ -47,41 +51,70 @@
<style lang="scss">
.toolbar {
align-items: center;
- background: var(--graphiql-panel, #252526);
- border-bottom: 1px solid var(--graphiql-border, #333);
+ background-color: var(--uchu-yang);
display: flex;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
}
- .run {
- background: var(--graphiql-accent, #0e639c);
+ .run,
+ .toggle {
+ align-items: center;
border: none;
- border-radius: 3px;
- color: #fff;
- cursor: pointer;
- font-size: 0.875rem;
- padding: 0.375rem 1rem;
+ display: flex;
+ flex-direction: row;
+ font-size: 0.8rem;
+ padding: 0.2rem 0.75rem 0.2rem 0.5rem;
+
+ &:not(:disabled) {
+ cursor: pointer;
+ }
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
+
+ svg {
+ width: 1rem; height: 1rem;
+
+ fill: currentColor;
+ margin-right: 0.25rem;
+ }
+ }
+
+ .run {
+ background-color: var(--uchu-orange-5);
+ color: var(--uchu-yang);
+
+ &:hover:not(:disabled) {
+ color: var(--uchu-orange-1);
+ }
}
.hint {
- color: var(--graphiql-muted, #858585);
+ color: var(--uchu-yin-5);
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;
+ border: none;
+ border-radius: 0;
+ font-size: 0.8rem;
+ padding: 0.2rem 0.75rem 0.2rem 0.5rem;
+
+ &:not(:focus-visible) {
+ background-color: var(--uchu-gray-1);
+ color: var(--uchu-yin-5);
+ }
+
+ &:focus-visible {
+ background-color: var(--uchu-orange-5);
+ color: var(--uchu-yang);
+ outline-color: var(--uchu-orange-5);
+ outline-offset: -1px;
+ outline-style: solid;
+ }
}
.spacer {
@@ -89,35 +122,28 @@
}
.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;
+ background-color: var(--uchu-gray-1);
+ color: var(--uchu-yin-5);
&.active {
- background: var(--graphiql-bg, #1e1e1e);
- color: var(--graphiql-fg, #d4d4d4);
- }
-
- &:disabled {
- cursor: not-allowed;
- opacity: 0.4;
+ background-color: var(--uchu-yin);
+ color: var(--uchu-yang);
}
- &:hover:not(:disabled) {
- color: var(--graphiql-fg, #d4d4d4);
+ &:not(.active):hover:not(:disabled) {
+ color: var(--uchu-yin-8);
}
}
</style>
<div class="toolbar">
<button class="run" {disabled} onclick={onRun}>
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+ <path d="M4 3.53711V20.4642L20.927 12.0006L4 3.53711Z"/>
+ </svg>
{running ? "Running…" : "Run"}
</button>
+
{#if namedOperations.length > 1}
<select
aria-label="Operation"
@@ -130,23 +156,43 @@
{/each}
</select>
{/if}
+
{#if onFormat}
- <button class="toggle" {disabled} onclick={onFormat} type="button">Format</button>
+ <button class="toggle" {disabled} onclick={onFormat} type="button">
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+ <path d="M20.646 3.36267C20.0461 2.75178 19.7917 1.83922 19.7917 1H19.2083C19.2083 1.83575 18.9563 2.74893 18.3526 3.35264C17.7489 3.95635 16.8358 4.20833 16 4.20833V4.79167C16.8392 4.79167 17.7518 5.04611 18.3627 5.64596C18.9822 6.25433 19.2083 7.15499 19.2083 8H19.7917C19.7917 7.15156 20.0203 6.25159 20.6359 5.63594C21.2516 5.02029 22.1516 4.79167 23 4.79167V4.20833C22.155 4.20833 21.2543 3.98225 20.646 3.36267Z"/>
+ <path d="M2 13.75C5.03345 13.75 7.04641 14.4196 8.31342 15.6866C9.58043 16.9536 10.25 18.9666 10.25 22H11.75C11.75 18.9666 12.4196 16.9536 13.6866 15.6866C14.9536 14.4196 16.9666 13.75 20 13.75V12.25C16.9666 12.25 14.9536 11.5804 13.6866 10.3134C12.4196 9.04641 11.75 7.03345 11.75 4H10.25C10.25 7.03345 9.58043 9.04641 8.31342 10.3134C7.04641 11.5804 5.03345 12.25 2 12.25V13.75Z"/>
+ </svg>
+ Format
+ </button>
{/if}
+
{#if extras}{@render extras()}{/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>
+ type="button">
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M7 8C7 8.55228 6.55228 9 6 9C5.44772 9 5 8.55228 5 8C5 7.44772 5.44772 7 6 7C6.55228 7 7 7.44772 7 8Z"/>
+ <path d="M10 8C10 8.55228 9.55228 9 9 9C8.44772 9 8 8.55228 8 8C8 7.44772 8.44772 7 9 7C9.55228 7 10 7.44772 10 8Z"/>
+ <path d="M12 9C12.5523 9 13 8.55228 13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8C11 8.55228 11.4477 9 12 9Z"/>
+ <path d="M2 4V20H11V18.5H3.5V5.5H20.5V9H22V4H2Z"/>
+ <path d="M13 16.75C14.6446 16.75 15.6575 17.114 16.2718 17.7282C16.886 18.3425 17.25 19.3554 17.25 21H18.75C18.75 19.3554 19.114 18.3425 19.7282 17.7282C20.3425 17.114 21.3554 16.75 23 16.75V15.25C21.3554 15.25 20.3425 14.886 19.7282 14.2718C19.114 13.6575 18.75 12.6446 18.75 11H17.25C17.25 12.6446 16.886 13.6575 16.2718 14.2718C15.6575 14.886 14.6446 15.25 13 15.25V16.75Z"/>
+ </svg>
+ History
+ </button>
{/if}
+
{#if onToggleDocs}
<button
aria-pressed={docsOpen}
@@ -154,6 +200,12 @@
class:active={docsOpen}
disabled={!docsAvailable}
onclick={onToggleDocs}
- type="button">Docs</button>
+ type="button">
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 4V5L16.5 6.5L18 9.5H19L20.5 6.5L21 6.25V21H3V3H15.5L13.5 4ZM7 16.5H14V15H7V16.5ZM7 12.75H10.5V11.25H7V12.75ZM12 12.75H17V11.25H12V12.75ZM7 9H13V7.5H7V9Z"/>
+ <path d="M18.5 1L19.4453 3.55468L22 4.5L19.4453 5.44532L18.5 8L17.5547 5.44532L15 4.5L17.5547 3.55468L18.5 1Z"/>
+ </svg>
+ Docs
+ </button>
{/if}
</div>
diff --git a/source/library/fetcher/apq.ts b/source/library/fetcher/apq.ts
index 7f9f3c1..854017b 100644
--- a/source/library/fetcher/apq.ts
+++ b/source/library/fetcher/apq.ts
@@ -54,7 +54,7 @@ export function createApqFetcher(options: ApqOptions): Fetcher {
};
}
-/*** INTERNAL ----------------------------------------- ***/
+/*** HELPER ------------------------------------------- ***/
async function getHash(cache: Map<string, string>, query: string): Promise<string> {
const cached = cache.get(query);
@@ -64,6 +64,7 @@ async function getHash(cache: Map<string, string>, query: string): Promise<strin
const hash = await sha256Hex(query);
cache.set(query, hash);
+
return hash;
}
diff --git a/source/library/fetcher/sse.ts b/source/library/fetcher/sse.ts
index c28436b..73a2da6 100644
--- a/source/library/fetcher/sse.ts
+++ b/source/library/fetcher/sse.ts
@@ -3,11 +3,19 @@
/*** IMPORT ------------------------------------------- ***/
-import { createClient, type Client, type ClientOptions } from "graphql-sse";
+import {
+ createClient,
+ type Client,
+ type ClientOptions
+} from "graphql-sse";
/*** UTILITY ------------------------------------------ ***/
-import type { Fetcher, FetcherOptions, FetcherResult } from "./types.ts";
+import type {
+ Fetcher,
+ FetcherOptions,
+ FetcherResult
+} from "./types.ts";
/*** EXPORT ------------------------------------------- ***/
diff --git a/source/library/graphiql.d.ts b/source/library/graphiql.d.ts
new file mode 100644
index 0000000..e3c5362
--- /dev/null
+++ b/source/library/graphiql.d.ts
@@ -0,0 +1 @@
+declare module "@inc/uchu/css";
diff --git a/source/library/graphql/markdown.ts b/source/library/graphql/markdown.ts
new file mode 100644
index 0000000..a2ca6f7
--- /dev/null
+++ b/source/library/graphql/markdown.ts
@@ -0,0 +1,45 @@
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+export function markdown(input: string): string {
+ const inlineCode: string[] = [];
+ const inlineCodePattern = /`([^`]+)`/g;
+ const refPattern = /\s*\[(\w+)\]\s+<([^>]+)>$/gm;
+ const refs = new Map<string, string>();
+
+ let processed = input.replace(inlineCodePattern, (_match, content) => {
+ const index = inlineCode.length;
+ inlineCode.push(content);
+ return `<!--INLINE:CODE:${index}-->`;
+ });
+
+ for (const match of processed.matchAll(refPattern)) {
+ const [, ref, url] = match;
+ refs.set(ref, url);
+ }
+
+ processed = processed
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" target="_blank">$1</a>`)
+ .replace(/(\*\*|__)(.*?)\1/g, `<strong style="white-space: nowrap;">$2</strong>`)
+ .replace(/'/g, "’")
+ .replace(/(\.\.\.)/g, "…")
+ .replace(/---/g, "<hr/>");
+
+ for (const block in inlineCode) {
+ processed = processed.replace(`<!--INLINE:CODE:${block}-->`, `<code>${escapeHtml(inlineCode[block])}</code>`);
+ }
+
+ return processed.trimEnd();
+}
+
+/*** HELPER ------------------------------------------- ***/
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;");
+}
diff --git a/source/library/index.ts b/source/library/index.ts
index d68955b..5f18a87 100644
--- a/source/library/index.ts
+++ b/source/library/index.ts
@@ -3,6 +3,8 @@
/*** EXPORT ------------------------------------------- ***/
+export type { Extension } from "@codemirror/state";
+
export { createApqFetcher } from "./fetcher/apq.ts";
export { createHttpFetcher } from "./fetcher/http.ts";
export { createLocalStorage, createMemoryStorage } from "./state/storage.ts";
@@ -17,7 +19,6 @@ export { SessionStore } from "./state/session.svelte.ts";
export { validateSessionExport } from "./state/session-io.ts";
export type { ApqOptions } from "./fetcher/apq.ts";
-export type { Extension } from "@codemirror/state";
export type {
Fetcher,
@@ -27,15 +28,18 @@ export type {
} from "./fetcher/types.ts";
export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts";
+
export type {
ImportResult,
SessionExport,
TabExport
} from "./state/session-io.ts";
+
export type { OperationInfo } from "./graphql/operations.ts";
export type { ShortcutAction } from "./state/keyboard.ts";
export type { SseFetcherOptions } from "./fetcher/sse.ts";
export type { Storage } from "./state/storage.ts";
+
export type {
RunOptions,
SubscriptionMode,
@@ -43,4 +47,5 @@ export type {
TabSeed,
TabTiming
} from "./state/session.svelte.ts";
+
export type { WsFetcherOptions } from "./fetcher/websocket.ts";
diff --git a/source/library/runes.d.ts b/source/library/runes.d.ts
deleted file mode 100644
index 4b73482..0000000
--- a/source/library/runes.d.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-/*** 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.svelte.ts b/source/library/state/history.svelte.ts
index 2726283..80de3c0 100644
--- a/source/library/state/history.svelte.ts
+++ b/source/library/state/history.svelte.ts
@@ -32,12 +32,10 @@ export type HistoryInput = {
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))
diff --git a/source/library/state/session-io.ts b/source/library/state/session-io.ts
index a5e2ea9..f2491ba 100644
--- a/source/library/state/session-io.ts
+++ b/source/library/state/session-io.ts
@@ -9,59 +9,14 @@ import type { Tab } from "./session.svelte.ts";
const MAX_STRING_BYTES = 1024 * 1024;
const MAX_TABS = 50;
-function isObject(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function stringTooLong(value: string): boolean {
- return value.length > MAX_STRING_BYTES;
-}
-
-function validateTab(raw: unknown, index: number): TabExport | string {
- if (!isObject(raw))
- return `tabs[${index}]: not an object`;
-
- if (typeof raw.headers !== "string")
- return `tabs[${index}].headers: not a string`;
-
- if (typeof raw.query !== "string")
- return `tabs[${index}].query: not a string`;
-
- if (typeof raw.title !== "string")
- return `tabs[${index}].title: not a string`;
-
- if (typeof raw.variables !== "string")
- return `tabs[${index}].variables: not a string`;
-
- if (raw.operationName !== null && typeof raw.operationName !== "string")
- return `tabs[${index}].operationName: not a string or null`;
-
- if (stringTooLong(raw.headers))
- return `tabs[${index}].headers: exceeds 1 MB`;
-
- if (stringTooLong(raw.query))
- return `tabs[${index}].query: exceeds 1 MB`;
-
- if (stringTooLong(raw.title))
- return `tabs[${index}].title: exceeds 1 MB`;
-
- if (stringTooLong(raw.variables))
- return `tabs[${index}].variables: exceeds 1 MB`;
-
- if (typeof raw.operationName === "string" && stringTooLong(raw.operationName))
- return `tabs[${index}].operationName: exceeds 1 MB`;
-
- return {
- headers: raw.headers,
- operationName: raw.operationName,
- query: raw.query,
- title: raw.title,
- variables: raw.variables
- };
-}
-
/*** EXPORT ------------------------------------------- ***/
+export type ImportResult = {
+ added: number;
+ errors: string[];
+ skipped: number;
+};
+
export type TabExport = {
headers: string;
operationName: string | null;
@@ -76,12 +31,6 @@ export type SessionExport = {
version: 1;
};
-export type ImportResult = {
- added: number;
- errors: string[];
- skipped: number;
-};
-
export function tabToExport(tab: Tab): TabExport {
return {
headers: tab.headers,
@@ -125,3 +74,56 @@ export function validateSessionExport(data: unknown): SessionExport | { error: s
version: 1
};
}
+
+/*** HELPER ------------------------------------------- ***/
+
+function isObject(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringTooLong(value: string): boolean {
+ return value.length > MAX_STRING_BYTES;
+}
+
+function validateTab(raw: unknown, index: number): TabExport | string {
+ if (!isObject(raw))
+ return `tabs[${index}]: not an object`;
+
+ if (typeof raw.headers !== "string")
+ return `tabs[${index}].headers: not a string`;
+
+ if (typeof raw.query !== "string")
+ return `tabs[${index}].query: not a string`;
+
+ if (typeof raw.title !== "string")
+ return `tabs[${index}].title: not a string`;
+
+ if (typeof raw.variables !== "string")
+ return `tabs[${index}].variables: not a string`;
+
+ if (raw.operationName !== null && typeof raw.operationName !== "string")
+ return `tabs[${index}].operationName: not a string or null`;
+
+ if (stringTooLong(raw.headers))
+ return `tabs[${index}].headers: exceeds 1 MB`;
+
+ if (stringTooLong(raw.query))
+ return `tabs[${index}].query: exceeds 1 MB`;
+
+ if (stringTooLong(raw.title))
+ return `tabs[${index}].title: exceeds 1 MB`;
+
+ if (stringTooLong(raw.variables))
+ return `tabs[${index}].variables: exceeds 1 MB`;
+
+ if (typeof raw.operationName === "string" && stringTooLong(raw.operationName))
+ return `tabs[${index}].operationName: exceeds 1 MB`;
+
+ return {
+ headers: raw.headers,
+ operationName: raw.operationName,
+ query: raw.query,
+ title: raw.title,
+ variables: raw.variables
+ };
+}
diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts
index 76777e5..f84bb17 100644
--- a/source/library/state/session.svelte.ts
+++ b/source/library/state/session.svelte.ts
@@ -3,29 +3,26 @@
/*** UTILITY ------------------------------------------ ***/
-import type { Fetcher, FetcherResult } from "../fetcher/types.ts";
import { format } from "../graphql/format.ts";
+
import {
deriveTitle,
parseOperations,
type OperationInfo
} from "../graphql/operations.ts";
+
import {
tabToExport,
type ImportResult,
type SessionExport,
type TabExport
} from "./session-io.ts";
+
+import type { Fetcher, FetcherResult } from "../fetcher/types.ts";
import type { Storage } from "./storage.ts";
const STORAGE_KEY = "session";
-function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
- return typeof value === "object" &&
- value !== null &&
- typeof (value as AsyncIterable<T>)[Symbol.asyncIterator] === "function";
-}
-
type Snapshot = {
activeId: string;
tabs: Tab[];
@@ -72,7 +69,6 @@ export class SessionStore {
activeId = $state<string>("");
tabs = $state<Tab[]>([]);
active = $derived(this.tabs.find((t) => t.id === this.activeId));
-
#storage: Storage;
constructor(storage: Storage) {
@@ -106,6 +102,7 @@ export class SessionStore {
if (this.tabs.length === 1) {
const fresh = this.#blank();
+
this.tabs = [fresh];
this.activeId = fresh.id;
@@ -154,8 +151,8 @@ export class SessionStore {
}
importTabs(data: SessionExport, opts: { mode: "append" | "replace" }): ImportResult {
- const errors: string[] = [];
const capped = data.tabs.slice(0, 50);
+ const errors: string[] = [];
const skipped = data.tabs.length - capped.length;
if (opts.mode === "replace") {
@@ -181,7 +178,11 @@ export class SessionStore {
if (capped.length > 0)
this.activeId = this.tabs[this.tabs.length - 1].id;
- return { added: capped.length, errors, skipped };
+ return {
+ added: capped.length,
+ errors,
+ skipped
+ };
}
nextTab() {
@@ -249,8 +250,8 @@ export class SessionStore {
tab.timing = { endMs: startMs, firstByteMs: startMs, startMs };
try {
- const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {};
const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {};
+ const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {};
const result = await fetcher({
headers,
@@ -312,13 +313,16 @@ export class SessionStore {
}
const firstByteMs = performance.now();
+
tab.timing = { ...tab.timing, firstByteMs };
tab.result = JSON.stringify(result, null, 2);
tab.timing = { ...tab.timing, endMs: performance.now() };
+
return true;
} catch(err) {
tab.result = JSON.stringify({ error: String(err) }, null, 2);
tab.timing = { ...tab.timing, endMs: performance.now() };
+
return false;
}
}
@@ -431,3 +435,11 @@ export class SessionStore {
};
}
}
+
+/*** HELPER ------------------------------------------- ***/
+
+function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
+ return typeof value === "object" &&
+ value !== null &&
+ typeof (value as AsyncIterable<T>)[Symbol.asyncIterator] === "function";
+}
diff --git a/source/library/styles/theme.scss b/source/library/styles/theme.scss
deleted file mode 100644
index 55f24aa..0000000
--- a/source/library/styles/theme.scss
+++ /dev/null
@@ -1,47 +0,0 @@
-// @eol/graphiql chrome theme variables.
-//
-// Override any variable under a scoped selector (e.g. `.graphiql`) or on :root
-// to re-skin the chrome. The editor theme is a separate prop (`theme?: Extension`).
-//
-// Supported variables:
-// --graphiql-accent primary action color (Run button, selected state)
-// --graphiql-bg base background (editor + result)
-// --graphiql-border divider lines between panes
-// --graphiql-fg primary foreground
-// --graphiql-font font-family for the chrome
-// --graphiql-link clickable links in docs / breadcrumbs
-// --graphiql-muted muted foreground (hints, timestamps)
-// --graphiql-panel panel background (toolbar, tabbar, headers)
-
-:root {
- --graphiql-accent: #0e639c;
- --graphiql-bg: #1e1e1e;
- --graphiql-border: #333;
- --graphiql-fg: #d4d4d4;
- --graphiql-font: ui-monospace, SFMono-Regular, monospace;
- --graphiql-link: #79b8ff;
- --graphiql-muted: #858585;
- --graphiql-panel: #252526;
-}
-
-@media (prefers-color-scheme: light) {
- :root:not(.graphiql-dark) {
- --graphiql-accent: #0366d6;
- --graphiql-bg: #ffffff;
- --graphiql-border: #e1e4e8;
- --graphiql-fg: #24292e;
- --graphiql-link: #0366d6;
- --graphiql-muted: #6a737d;
- --graphiql-panel: #f6f8fa;
- }
-}
-
-:root.graphiql-light {
- --graphiql-accent: #0366d6;
- --graphiql-bg: #ffffff;
- --graphiql-border: #e1e4e8;
- --graphiql-fg: #24292e;
- --graphiql-link: #0366d6;
- --graphiql-muted: #6a737d;
- --graphiql-panel: #f6f8fa;
-}
diff --git a/source/library/themes/light.ts b/source/library/themes/light.ts
index daaede2..3e20423 100644
--- a/source/library/themes/light.ts
+++ b/source/library/themes/light.ts
@@ -3,18 +3,19 @@
/*** IMPORT ------------------------------------------- ***/
+import { color, yang } from "@inc/uchu";
+import { Decoration, drawSelection, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
-import { EditorView } from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
-/*** EXPORT ------------------------------------------- ***/
+/*** UTILITY ------------------------------------------ ***/
-const BG = "#fafafa";
-const BORDER = "#e0e0e0";
-const FG = "#24292e";
-const GUTTER_BG = "#f3f3f3";
-const GUTTER_FG = "#9ca3af";
-const SELECTION = "#b3d4fc";
+const BG = yang;
+const BORDER = color("gray", 1);
+const FG = color("yin", 3);
+const GUTTER_BG = color("gray", 1);
+const GUTTER_FG = color("gray", 6);
+const SELECTION = color("yellow", 1);
const base = EditorView.theme({
"&": {
@@ -24,18 +25,21 @@ const base = EditorView.theme({
"&.cm-focused": {
outline: "none"
},
+ "&.cm-focused .cm-matchingBracket": {
+ backgroundColor: color("gray", 1)
+ },
+ "&.cm-focused .cm-selectionBackground, ::selection": {
+ backgroundColor: SELECTION
+ },
".cm-activeLine": {
- backgroundColor: "#f0f0f0"
+ backgroundColor: color("gray", 1)
},
".cm-activeLineGutter": {
backgroundColor: "transparent",
color: FG
},
".cm-content": {
- caretColor: "#1f6feb"
- },
- ".cm-cursor, .cm-dropCursor": {
- borderLeftColor: "#1f6feb"
+ padding: 0
},
".cm-gutters": {
backgroundColor: GUTTER_BG,
@@ -43,33 +47,101 @@ const base = EditorView.theme({
borderRight: `1px solid ${BORDER}`,
color: GUTTER_FG
},
+ ".cm-lineNumbers .cm-gutterElement": {
+ padding: "0 5px 0 15px"
+ },
+ ".cm-lintPoint::after": {
+ borderBottomColor: color("red", 5)
+ },
".cm-matchingBracket": {
- backgroundColor: "#dbeafe",
- outline: "1px solid #93c5fd"
+ backgroundColor: color("gray", 1),
+ color: color("yin", 8),
+ outline: "none"
+ },
+ ".cm-scroller": {
+ fontSize: "0.8rem",
+ lineHeight: 1.3
},
".cm-selectionBackground, &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
backgroundColor: SELECTION
}
}, { dark: false });
+const blockCaretTheme = EditorView.theme({
+ "&": {
+ caretColor: "transparent"
+ },
+ "& .cm-cursor": {
+ borderLeftColor: color("blue", 5),
+ borderLeftStyle: "solid",
+ borderLeftWidth: "1ch",
+ marginLeft: "0"
+ },
+ "& .cm-cursorLayer": {
+ mixBlendMode: "hard-light",
+ zIndex: "auto"
+ },
+ "& .cm-scroller": {
+ isolation: "isolate"
+ }
+});
+
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" }
+ {
+ color: color("yin", 8),
+ tag: [
+ t.atom,
+ t.attributeName,
+ t.bool,
+ t.character,
+ t.className,
+ t.comment,
+ t.deleted,
+ t.documentMeta,
+ t.keyword,
+ t.heading,
+ t.labelName,
+ t.link,
+ t.macroName,
+ t.meta,
+ t.name,
+ t.null,
+ t.operator,
+ t.operatorKeyword,
+ t.propertyName,
+ t.tagName,
+ t.typeName,
+ t.variableName
+ ]
+ },
+ {
+ backgroundColor: color("green", 1),
+ color: color("green", 9),
+ tag: [t.string, t.special(t.string)]
+ },
+ {
+ color: color("purple", 4),
+ tag: [t.number]
+ },
+ {
+ fontStyle: "italic",
+ tag: [t.comment, t.emphasis]
+ },
+ {
+ fontWeight: "bold",
+ tag: [t.strong]
+ },
+ {
+ tag: t.strikethrough,
+ textDecoration: "line-through"
+ }
]);
-export const lightTheme = [base, syntaxHighlighting(highlight)];
+/*** EXPORT ------------------------------------------- ***/
+
+export const lightTheme = [
+ drawSelection(),
+ base,
+ syntaxHighlighting(highlight),
+ blockCaretTheme
+];
diff --git a/svelte.config.js b/svelte.config.js
index c56f5d3..d5b3f97 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -1,5 +1,20 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+import { sveltePreprocess } from "svelte-preprocess";
+
+/*** EXPORT ------------------------------------------- ***/
+
+const isPackaging = process.argv[1]?.endsWith("svelte-package.js")
+ || process.env.npm_lifecycle_event === "package"
+ || process.env.npm_lifecycle_event === "prepublishOnly"
+ || process.env.npm_lifecycle_event === "publish";
export default {
- preprocess: vitePreprocess()
+ preprocess: isPackaging
+ ? [sveltePreprocess({ scss: { silenceDeprecations: ["legacy-js-api"] } })]
+ : [vitePreprocess()]
};
diff --git a/tests/apq.test.ts b/tests/apq.test.ts
index 66607cd..2097377 100644
--- a/tests/apq.test.ts
+++ b/tests/apq.test.ts
@@ -10,43 +10,9 @@ import { expect, test } from "vitest";
import { createApqFetcher } from "../source/library/fetcher/apq.ts";
-/*** HELPERS ------------------------------------------ ***/
-
type Call = { body: Record<string, unknown>; url: string };
-function createStub(queue: Array<Record<string, unknown>>): {
- calls: Call[];
- stub: typeof fetch;
-} {
- const calls: Call[] = [];
-
- const stub: typeof fetch = (input, init) => {
- const body = JSON.parse((init?.body as string) ?? "null") as Record<string, unknown>;
- calls.push({ body, url: String(input) });
-
- const next = queue.shift();
-
- if (next === undefined)
- throw new Error("stub fetch exhausted");
-
- return Promise.resolve(new Response(JSON.stringify(next), { status: 200 }));
- };
-
- return { calls, stub };
-}
-
-async function expectedHash(query: string): Promise<string> {
- const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(query));
- return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
-}
-
-function readPersistedHash(body: Record<string, unknown>): string {
- const extensions = body.extensions as Record<string, unknown>;
- const persistedQuery = extensions.persistedQuery as Record<string, unknown>;
- return persistedQuery.sha256Hash as string;
-}
-
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("apq cache miss retries with the full query", async () => {
const { calls, stub } = createStub([
@@ -149,3 +115,37 @@ test("apq accepts PERSISTED_QUERY_NOT_FOUND extension code", async () => {
expect(calls.length).toEqual(2);
expect(calls[1].body.query).toEqual("{ hello }");
});
+
+/*** HELPER ------------------------------------------- ***/
+
+function createStub(queue: Array<Record<string, unknown>>): {
+ calls: Call[];
+ stub: typeof fetch;
+} {
+ const calls: Call[] = [];
+
+ const stub: typeof fetch = (input, init) => {
+ const body = JSON.parse((init?.body as string) ?? "null") as Record<string, unknown>;
+ calls.push({ body, url: String(input) });
+
+ const next = queue.shift();
+
+ if (next === undefined)
+ throw new Error("stub fetch exhausted");
+
+ return Promise.resolve(new Response(JSON.stringify(next), { status: 200 }));
+ };
+
+ return { calls, stub };
+}
+
+async function expectedHash(query: string): Promise<string> {
+ const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(query));
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+function readPersistedHash(body: Record<string, unknown>): string {
+ const extensions = body.extensions as Record<string, unknown>;
+ const persistedQuery = extensions.persistedQuery as Record<string, unknown>;
+ return persistedQuery.sha256Hash as string;
+}
diff --git a/tests/format.test.ts b/tests/format.test.ts
index ae5d40c..e0b9f1b 100644
--- a/tests/format.test.ts
+++ b/tests/format.test.ts
@@ -10,11 +10,12 @@ import { expect, test } from "vitest";
import { format } from "../source/library/graphql/format.ts";
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("format pretty-prints a valid query", () => {
const input = "query Foo{viewer{id name}}";
const expected = "query Foo {\n viewer {\n id\n name\n }\n}";
+
expect(format(input)).toEqual(expected);
});
diff --git a/tests/history.test.ts b/tests/history.test.ts
index a08c014..581c0ef 100644
--- a/tests/history.test.ts
+++ b/tests/history.test.ts
@@ -16,11 +16,7 @@ type Entry = {
timestamp: number;
};
-function entry(id: string, timestamp: number, favorite = false): Entry {
- return { favorite, id, timestamp };
-}
-
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("evict keeps everything when under cap", () => {
const entries = [entry("a", 3), entry("b", 2), entry("c", 1)];
@@ -35,7 +31,9 @@ test("evict drops the oldest non-favorites above cap", () => {
entry("d", 2),
entry("e", 1)
];
+
const kept = evict(entries, 3);
+
expect(kept.map((e) => e.id)).toEqual(["a", "b", "c"]);
});
@@ -47,6 +45,7 @@ test("evict never drops favorites", () => {
entry("c", 8),
entry("d", 7)
];
+
const kept = evict(entries, 3);
expect(kept.some((e) => e.id === "fav-old")).toEqual(true);
@@ -60,6 +59,7 @@ test("evict can exceed cap when favorites alone do so", () => {
entry("fav-3", 3, true),
entry("regular", 2)
];
+
const kept = evict(entries, 2);
expect(kept.length).toEqual(3);
@@ -73,6 +73,14 @@ test("evict sorts by timestamp descending", () => {
entry("b", 2),
entry("d", 0)
];
+
const kept = evict(entries, 3);
+
expect(kept.map((e) => e.id)).toEqual(["a", "b", "c"]);
});
+
+/*** HELPER ------------------------------------------- ***/
+
+function entry(id: string, timestamp: number, favorite = false): Entry {
+ return { favorite, id, timestamp };
+}
diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts
index 3a7f3cc..6550e98 100644
--- a/tests/keyboard.test.ts
+++ b/tests/keyboard.test.ts
@@ -18,17 +18,7 @@ type EventInit = {
shiftKey?: boolean;
};
-function makeEvent(init: EventInit): KeyboardEvent {
- return {
- altKey: init.altKey ?? false,
- ctrlKey: init.ctrlKey ?? false,
- key: init.key,
- metaKey: init.metaKey ?? false,
- shiftKey: init.shiftKey ?? false
- } as KeyboardEvent;
-}
-
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("matchShortcut returns null for plain Enter", () => {
expect(matchShortcut(makeEvent({ key: "Enter" }))).toEqual(null);
@@ -109,3 +99,15 @@ test("matchShortcut ignores Cmd+Alt+Enter", () => {
matchShortcut(makeEvent({ altKey: true, key: "Enter", metaKey: true }))
).toEqual(null);
});
+
+/*** HELPER ------------------------------------------- ***/
+
+function makeEvent(init: EventInit): KeyboardEvent {
+ return {
+ altKey: init.altKey ?? false,
+ ctrlKey: init.ctrlKey ?? false,
+ key: init.key,
+ metaKey: init.metaKey ?? false,
+ shiftKey: init.shiftKey ?? false
+ } as KeyboardEvent;
+}
diff --git a/tests/operations.test.ts b/tests/operations.test.ts
index 14fe768..6acca31 100644
--- a/tests/operations.test.ts
+++ b/tests/operations.test.ts
@@ -8,9 +8,12 @@ import { expect, test } from "vitest";
/*** UTILITY ------------------------------------------ ***/
-import { deriveTitle, parseOperations } from "../source/library/graphql/operations.ts";
+import {
+ deriveTitle,
+ parseOperations
+} from "../source/library/graphql/operations.ts";
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("parseOperations returns empty for blank query", () => {
expect(parseOperations("")).toEqual([]);
@@ -37,6 +40,7 @@ test("parseOperations captures multiple operations", () => {
mutation Bar { b }
subscription Baz { c }
`);
+
expect(ops).toEqual([
{ name: "Foo", type: "query" },
{ name: "Bar", type: "mutation" },
diff --git a/tests/session-io.test.ts b/tests/session-io.test.ts
index 6de919b..88c1ee6 100644
--- a/tests/session-io.test.ts
+++ b/tests/session-io.test.ts
@@ -16,29 +16,7 @@ import {
type TabInput = Parameters<typeof tabToExport>[0];
-function validExport(): SessionExport {
- return {
- exportedAt: "2026-04-24T00:00:00.000Z",
- tabs: [
- {
- headers: "{}",
- operationName: "MyOp",
- query: "query MyOp { hello }",
- title: "MyOp",
- variables: "{}"
- }
- ],
- version: 1
- };
-}
-
-function isError(result: unknown): result is { error: string } {
- return typeof result === "object" &&
- result !== null &&
- "error" in result;
-}
-
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("validateSessionExport round-trips a valid payload unchanged", () => {
const data = validExport();
@@ -58,6 +36,7 @@ test("validateSessionExport rejects non-object input", () => {
test("validateSessionExport rejects wrong version", () => {
const zero = validateSessionExport({ ...validExport(), version: 0 });
const two = validateSessionExport({ ...validExport(), version: 2 });
+
const missing = validateSessionExport({
exportedAt: "2026-04-24T00:00:00.000Z",
tabs: []
@@ -142,6 +121,7 @@ test("validateSessionExport accepts null operationName", () => {
test("validateSessionExport rejects string field > 1 MB", () => {
const big = "x".repeat(1024 * 1024 + 1);
+
const result = validateSessionExport({
exportedAt: "2026-04-24T00:00:00.000Z",
tabs: [
@@ -167,6 +147,7 @@ test("validateSessionExport rejects > 50 tabs", () => {
title: "t",
variables: "{}"
}));
+
const result = validateSessionExport({
exportedAt: "2026-04-24T00:00:00.000Z",
tabs,
@@ -199,3 +180,27 @@ test("tabToExport strips id, result, operations, titleDirty", () => {
variables: "{}"
});
});
+
+/*** HELPER ------------------------------------------- ***/
+
+function isError(result: unknown): result is { error: string } {
+ return typeof result === "object" &&
+ result !== null &&
+ "error" in result;
+}
+
+function validExport(): SessionExport {
+ return {
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: [
+ {
+ headers: "{}",
+ operationName: "MyOp",
+ query: "query MyOp { hello }",
+ title: "MyOp",
+ variables: "{}"
+ }
+ ],
+ version: 1
+ };
+}
diff --git a/tests/storage.test.ts b/tests/storage.test.ts
index 434a67d..3fbbdd9 100644
--- a/tests/storage.test.ts
+++ b/tests/storage.test.ts
@@ -13,47 +13,11 @@ import {
createMemoryStorage
} from "../source/library/state/storage.ts";
-/*** HELPERS ------------------------------------------ ***/
-
-function installLocalStorage(): void {
- if (typeof globalThis.localStorage !== "undefined")
- return;
-
- const store = new Map<string, string>();
-
- const shim: Storage = {
- clear(): void {
- store.clear();
- },
- getItem(key: string): string | null {
- return store.has(key) ? store.get(key) ?? null : null;
- },
- key(index: number): string | null {
- return Array.from(store.keys())[index] ?? null;
- },
- get length(): number {
- return store.size;
- },
- removeItem(key: string): void {
- store.delete(key);
- },
- setItem(key: string, value: string): void {
- store.set(key, String(value));
- }
- };
-
- Object.defineProperty(globalThis, "localStorage", {
- configurable: true,
- value: shim,
- writable: true
- });
-}
-
beforeAll(() => {
installLocalStorage();
});
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("memory storage round-trips objects", () => {
const storage = createMemoryStorage();
@@ -68,15 +32,19 @@ test("memory storage returns null for missing keys", () => {
test("memory storage remove clears a key", () => {
const storage = createMemoryStorage();
+
storage.set("k", 42);
storage.remove("k");
+
expect(storage.get("k")).toEqual(null);
});
test("memory storage instances are isolated", () => {
const a = createMemoryStorage();
const b = createMemoryStorage();
+
a.set("shared", 1);
+
expect(b.get("shared")).toEqual(null);
});
@@ -105,8 +73,8 @@ test("local storage remove respects the namespace", () => {
alpha.set("k", 1);
beta.set("k", 2);
-
alpha.remove("k");
+
expect(alpha.get("k")).toEqual(null);
expect(beta.get<number>("k")).toEqual(2);
@@ -122,3 +90,39 @@ test("local storage returns null on malformed JSON", () => {
globalThis.localStorage.clear();
});
+
+/*** HELPER ------------------------------------------- ***/
+
+function installLocalStorage(): void {
+ if (typeof globalThis.localStorage !== "undefined")
+ return;
+
+ const store = new Map<string, string>();
+
+ const shim: Storage = {
+ clear(): void {
+ store.clear();
+ },
+ getItem(key: string): string | null {
+ return store.has(key) ? store.get(key) ?? null : null;
+ },
+ key(index: number): string | null {
+ return Array.from(store.keys())[index] ?? null;
+ },
+ get length(): number {
+ return store.size;
+ },
+ removeItem(key: string): void {
+ store.delete(key);
+ },
+ setItem(key: string, value: string): void {
+ store.set(key, String(value));
+ }
+ };
+
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: shim,
+ writable: true
+ });
+}
diff --git a/tests/timing.test.ts b/tests/timing.test.ts
index e2a0a06..589443d 100644
--- a/tests/timing.test.ts
+++ b/tests/timing.test.ts
@@ -8,15 +8,15 @@ import { expect, test } from "vitest";
/*** UTILITY ------------------------------------------ ***/
-import type { Fetcher, FetcherResult } from "../source/library/fetcher/types.ts";
-import { SessionStore } from "../source/library/state/session.svelte.ts";
import { createMemoryStorage } from "../source/library/state/storage.ts";
+import { SessionStore } from "../source/library/state/session.svelte.ts";
-function delay(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
+import {
+ type Fetcher,
+ type FetcherResult
+} from "../source/library/fetcher/types.ts";
-/*** TESTS -------------------------------------------- ***/
+/*** PROGRAM ------------------------------------------ ***/
test("run() populates timing for a one-shot fetcher", async () => {
const store = new SessionStore(createMemoryStorage());
@@ -54,8 +54,10 @@ test("run() records intervals between async iterable payloads", async () => {
async function* stream(): AsyncGenerator<FetcherResult> {
yield { data: { n: 1 } };
await delay(5);
+
yield { data: { n: 2 } };
await delay(5);
+
yield { data: { n: 3 } };
}
@@ -88,6 +90,7 @@ test("run() resets timing and streamIntervals on each invocation", async () => {
async function* stream(): AsyncGenerator<FetcherResult> {
yield { data: { n: 1 } };
await delay(5);
+
yield { data: { n: 2 } };
}
@@ -98,3 +101,9 @@ test("run() resets timing and streamIntervals on each invocation", async () => {
expect(tab.streamIntervals.length).toEqual(0);
expect(tab.timing).not.toBeNull();
});
+
+/*** HELPER ------------------------------------------- ***/
+
+function delay(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/tsconfig.json b/tsconfig.json
index 6bed1c6..a2271d0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,5 +13,8 @@
"target": "ES2022",
"verbatimModuleSyntax": true
},
- "include": ["source/library/**/*.ts", "source/library/**/*.svelte"]
+ "include": [
+ "source/library/**/*.ts",
+ "source/library/**/*.svelte"
+ ]
}
diff --git a/vite.config.ts b/vite.config.ts
index be9ad40..01543da 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,8 +1,25 @@
-import { svelte } from "@sveltejs/vite-plugin-svelte";
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
import { defineConfig } from "vite";
+import { svelte, vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+/*** EXPORT ------------------------------------------- ***/
export default defineConfig({
- plugins: [svelte()],
+ plugins: [
+ svelte({
+ preprocess: vitePreprocess({
+ style: {
+ scss: {
+ loadPaths: ["node_modules"]
+ }
+ }
+ })
+ })
+ ],
root: "playground",
server: {
port: 5173
diff --git a/vitest.config.ts b/vitest.config.ts
index c24a061..8b21dc2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,8 +1,15 @@
-import { svelte } from "@sveltejs/vite-plugin-svelte";
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
import { defineConfig } from "vitest/config";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+
+/*** EXPORT ------------------------------------------- ***/
export default defineConfig({
- plugins: [svelte({ hot: false })],
+ plugins: [svelte()],
test: {
environment: "node",
include: ["tests/**/*.test.ts"]