diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index 78427b9..e4a1122 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -18,7 +18,7 @@ test('editing the config re-runs the merge and surfaces config errors', async ({ await page.keyboard.press('ControlOrMeta+a'); await page.keyboard.type('sources: [{name: x}]'); - await expect(page.locator('#problem-count')).toHaveText('(1)'); + await expect(page.locator('#problem-count')).toHaveText('1'); await page.getByRole('tab', { name: /Problems/ }).click(); await expect(page.locator('#problems')).toContainText("either 'path' or 'url'"); }); diff --git a/web/index.html b/web/index.html index 4d8f0f9..d48d050 100644 --- a/web/index.html +++ b/web/index.html @@ -3,66 +3,184 @@ - OpenAPI Aggregator Playground - + Merge OpenAPI specs online | openapi-aggregator playground + + + + + + + + + + + + + + + + + + + + + + + -
-

openapi-aggregator playground

- - - GitHub -
- - -
-
-

Workspace

- -
- - - + +
+
+ + + openapi-aggregator + +

openapi-aggregator playground: merge OpenAPI specs online

+

Merge OpenAPI specs from many services into one

+
+ + + + +
-
+
-

Config openapi-aggregator.yaml

-
-
+ -
- Settings - -

Prepended to every fetched URL. Stored only in this browser.

-

Variables

-

Values for ${VAR} in source URLs and headers. Stored only in this browser and never included in share links.

-
- -
-
+ -
-
- - - -
-
-
- - +
+
- - -
-
+ + + +
+ Settings + +

Added in front of every fetched URL. Stored only in this browser.

+
+ Variables +

Values for ${VAR} in source URLs and headers. Stored only in this browser and never put in share links.

+
+ +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ +
+ +
+
+
+ + + +
+
+ + +
+
+
+ + +
+ + + + + + diff --git a/web/package-lock.json b/web/package-lock.json index 3f50239..080e1fb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,9 +7,13 @@ "name": "openapi-aggregator-playground", "dependencies": { "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.6", "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.13", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@scalar/api-reference": "^1.72.3", "codemirror": "^6.0.2" }, @@ -355,6 +359,24 @@ "vue-demi": ">=0.13.0" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@headlessui/tailwindcss": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", diff --git a/web/package.json b/web/package.json index 667ed25..430d617 100644 --- a/web/package.json +++ b/web/package.json @@ -20,9 +20,13 @@ }, "dependencies": { "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.6", "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.13", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@scalar/api-reference": "^1.72.3", "codemirror": "^6.0.2" } diff --git a/web/public/og-image.png b/web/public/og-image.png new file mode 100644 index 0000000..169d65f Binary files /dev/null and b/web/public/og-image.png differ diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml new file mode 100644 index 0000000..4a933ef --- /dev/null +++ b/web/public/sitemap.xml @@ -0,0 +1,8 @@ + + + + https://includeamin.github.io/openapi-aggregator/ + monthly + 1.0 + + diff --git a/web/src/app.ts b/web/src/app.ts index b523a74..a2e7fdb 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -4,7 +4,8 @@ import { latestOnly, runPipeline, type PipelineResult } from './pipeline'; import { errorMessage, fetchText, guardLoader, memoizeLoader } from './resolve'; import { loadSettings, saveSettings } from './settings'; import { decodeShare, encodeShare, MAX_SHARE_LENGTH, shareParam } from './share'; -import type { Problem, WorkspaceFile } from './types'; +import { fileHues, problemCountLabel, sourceChips, statusOf } from './sources'; +import type { Problem, SourceSummary, WorkspaceFile } from './types'; import { createYamlEditor } from './ui/editor'; import { showReference } from './ui/reference'; import { fileNameFromUrl, isNameTaken, uniqueFileName, upsertFile } from './workspace'; @@ -22,19 +23,49 @@ function $(selector: string): T { return el; } -function button(label: string, onClick: () => void): HTMLButtonElement { +const ICONS = { + pencil: '', + trash: '', + x: '', + alert: '', +} as const; + +/** An SVG icon from the constant set above (never user content). */ +function icon(name: keyof typeof ICONS): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('icon'); + svg.innerHTML = ICONS[name]; + return svg; +} + +function iconButton(name: keyof typeof ICONS, label: string, onClick: () => void): HTMLButtonElement { const b = document.createElement('button'); b.type = 'button'; - b.textContent = label; + b.className = 'icon-btn'; + b.title = label; + b.setAttribute('aria-label', label); + b.append(icon(name)); b.onclick = onClick; return b; } +function el(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +const INTRO_KEY = 'openapi-aggregator.intro-dismissed'; + export async function startApp(): Promise { const settings = loadSettings(); const state = { config: '', files: [] as WorkspaceFile[], selected: 0 }; let result: PipelineResult = { text: null, format: 'yaml', problems: [] }; let activeTab = 'merged'; + let activeEditor: 'config' | 'file' = 'config'; let timer: ReturnType | undefined; let noticeTimer: ReturnType | undefined; @@ -73,30 +104,63 @@ export async function startApp(): Promise { timer = setTimeout(run, 300); } + function currentSources(): SourceSummary[] { + try { + return parseConfig(state.config).sources; + } catch { + return []; + } + } + async function run(): Promise { + const status = $('#status'); + status.dataset.state = 'busy'; + $('#status-label').textContent = 'Merging'; const next = await pipeline(); if (!next) return; // superseded by a newer run result = next; - $('#output').textContent = result.text ?? 'No output. See the Problems tab.'; + $('#output').textContent = result.text ?? 'No merged spec yet. Open the Problems tab to see what needs fixing.'; renderProblems(result.problems); renderConsent(); + renderStatus(); + renderFiles(); const configError = result.problems.find((p) => p.kind === 'config'); configEditor.showError(configError?.line, configError?.message); if (activeTab === 'reference' && result.text) void showReference($('#reference'), result.text); } function renderProblems(problems: Problem[]): void { - $('#problem-count').textContent = problems.length ? `(${problems.length})` : ''; + $('#problem-count').textContent = problems.length ? String(problems.length) : ''; + if (!problems.length) { + $('#problems').replaceChildren(el('li', 'empty', 'No problems. Every source loaded and merged cleanly.')); + return; + } $('#problems').replaceChildren( ...problems.map((p) => { - const li = document.createElement('li'); - li.className = p.level; - li.textContent = (p.source ? `[${p.source}] ` : '') + p.message; + const li = el('li', `problem ${p.level}`); + li.append(icon('alert'), el('span', 'problem-source', p.source ?? (p.kind === 'config' ? 'config' : 'merge'))); + li.append(el('span', 'problem-message', p.message)); return li; }), ); } + function renderStatus(): void { + const { state: runState, label } = statusOf(result); + $('#status').dataset.state = runState; + $('#status-label').textContent = label; + $('#status-sources').replaceChildren( + ...sourceChips(currentSources()).map((chip) => { + const span = el('span', `chip hue-${chip.hue}`); + span.title = chip.kind === 'url' ? 'Fetched from a URL' : 'Workspace file'; + span.append(el('span', 'dot'), chip.name); + return span; + }), + ); + $('#status-problems').textContent = problemCountLabel(result.problems.length); + $('#status-format').textContent = result.format.toUpperCase(); + } + function renderConsent(): void { const blocked = !remoteAllowed && result.problems.some((p) => p.kind === 'blocked'); $('#consent').hidden = !blocked; @@ -119,13 +183,18 @@ export async function startApp(): Promise { } function renderFiles(): void { + const hues = fileHues(currentSources(), state.files); $('#files').replaceChildren( ...state.files.map((file, index) => { - const li = document.createElement('li'); - if (index === state.selected) li.className = 'selected'; - const name = button(file.name, () => selectFile(index)); - name.className = 'file-name'; - const rename = button('Rename', () => { + const li = el('li', index === state.selected && activeEditor === 'file' ? 'file selected' : 'file'); + const open = el('button', 'file-open'); + open.type = 'button'; + const hue = hues[index]; + const dot = el('span', hue === null ? 'dot' : `dot hue-${hue}`); + dot.title = hue === null ? 'Not used by the config' : 'Used by the config'; + open.append(dot, el('span', '', file.name)); + open.onclick = () => selectFile(index, true); + const rename = iconButton('pencil', `Rename ${file.name}`, () => { const next = prompt('File name (as referenced by `path:` in the config)', file.name)?.trim(); if (next && isNameTaken(state.files, index, next)) { notice(`A file named '${next}' already exists.`); @@ -135,26 +204,45 @@ export async function startApp(): Promise { schedule(); } }); - const remove = button('Delete', () => { + const remove = iconButton('trash', `Delete ${file.name}`, () => { state.files.splice(index, 1); - selectFile(Math.min(state.selected, state.files.length - 1)); + selectFile(Math.min(state.selected, state.files.length - 1), activeEditor === 'file' && state.files.length > 0); schedule(); }); - li.append(name, rename, remove); + const actions = el('span', 'file-actions'); + actions.append(rename, remove); + li.append(open, actions); return li; }), ); - $('#file-editor').hidden = state.files.length === 0; + $('#files-empty').hidden = state.files.length > 0; + const fileTab = $('#file-tab'); + fileTab.hidden = state.files.length === 0; + fileTab.textContent = state.files[state.selected]?.name ?? ''; + if (!state.files.length && activeEditor === 'file') showEditor('config'); } - function selectFile(index: number): void { + function showEditor(which: 'config' | 'file'): void { + activeEditor = which; + for (const tab of document.querySelectorAll('.editor-tabs [role=tab]')) { + tab.setAttribute('aria-selected', String(tab.dataset.editor === which)); + } + for (const panel of document.querySelectorAll('[data-editor-panel]')) { + panel.hidden = panel.dataset.editorPanel !== which; + } + $('#copy-config').hidden = which !== 'config'; + renderFiles(); + } + + function selectFile(index: number, open = false): void { state.selected = Math.max(0, index); fileEditor.setDoc(state.files[state.selected]?.content ?? ''); - renderFiles(); + if (open) showEditor('file'); + else renderFiles(); } function addFile(file: WorkspaceFile): void { - selectFile(upsertFile(state.files, file)); + selectFile(upsertFile(state.files, file), true); schedule(); } @@ -163,6 +251,7 @@ export async function startApp(): Promise { state.files = files.map((f) => ({ ...f })); configEditor.setDoc(config); selectFile(0); + showEditor('config'); void run(); } @@ -217,8 +306,7 @@ export async function startApp(): Promise { function renderVariables(): void { $('#variables').replaceChildren( ...Object.entries(settings.variables).map(([key, value]) => { - const row = document.createElement('div'); - row.className = 'row'; + const row = el('div', 'variable'); const keyInput = Object.assign(document.createElement('input'), { value: key, placeholder: 'NAME' }); const valueInput = Object.assign(document.createElement('input'), { value, placeholder: 'value', type: 'password' }); const update = () => { @@ -229,7 +317,9 @@ export async function startApp(): Promise { }; keyInput.onchange = update; valueInput.onchange = update; - row.append(keyInput, valueInput, button('Remove', () => { + keyInput.setAttribute('aria-label', 'Variable name'); + valueInput.setAttribute('aria-label', `Value of ${key}`); + row.append(keyInput, valueInput, iconButton('x', `Remove ${key}`, () => { delete settings.variables[key]; settingsChanged(); renderVariables(); @@ -248,19 +338,42 @@ export async function startApp(): Promise { }; renderVariables(); + // --- editor tabs --- + for (const tab of document.querySelectorAll('.editor-tabs [role=tab]')) { + tab.onclick = () => showEditor(tab.dataset.editor as 'config' | 'file'); + } + // --- output --- - for (const tab of document.querySelectorAll('[role=tab]')) { + const outputTabs = document.querySelectorAll('.output-tabs [role=tab]'); + for (const tab of outputTabs) { tab.onclick = () => { activeTab = tab.dataset.tab!; - for (const t of document.querySelectorAll('[role=tab]')) { - t.setAttribute('aria-selected', String(t === tab)); - } + for (const t of outputTabs) t.setAttribute('aria-selected', String(t === tab)); for (const panel of document.querySelectorAll('[data-panel]')) { panel.hidden = panel.dataset.panel !== activeTab; } + for (const actions of document.querySelectorAll('[data-panel-actions]')) { + actions.hidden = actions.dataset.panelActions !== activeTab; + } if (activeTab === 'reference' && result.text) void showReference($('#reference'), result.text); }; } + + // --- intro --- + const intro = $('#intro'); + try { + intro.hidden = localStorage.getItem(INTRO_KEY) === '1'; + } catch { + intro.hidden = false; + } + $('#dismiss-intro').onclick = () => { + intro.hidden = true; + try { + localStorage.setItem(INTRO_KEY, '1'); + } catch { + // storage blocked: the strip just comes back next visit + } + }; $('#allow-fetch').onclick = () => { remoteAllowed = true; $('#consent').hidden = true; @@ -268,10 +381,12 @@ export async function startApp(): Promise { }; $('#copy-config').onclick = async () => { await navigator.clipboard.writeText(state.config); - notice('Config copied. Run it with: openapi-aggregator -c openapi-aggregator.yaml'); + notice('Config copied. Save it as openapi-aggregator.yaml and run openapi-aggregator.'); }; $('#copy-output').onclick = async () => { - if (result.text) await navigator.clipboard.writeText(result.text); + if (!result.text) return; + await navigator.clipboard.writeText(result.text); + notice('Merged spec copied.'); }; $('#download-output').onclick = () => { if (!result.text) return; @@ -292,7 +407,7 @@ export async function startApp(): Promise { const url = `${location.origin}${location.pathname}#s=${encoded}`; history.replaceState(null, '', url); await navigator.clipboard.writeText(url); - notice('Share link copied. It contains the config and workspace files, never your variables or proxy.'); + notice('Share link copied. It includes the config and files, but not your variables or proxy.'); }; // --- startup --- diff --git a/web/src/main.ts b/web/src/main.ts index 4dbd112..f111ffa 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,3 +1,8 @@ +import '@fontsource/ibm-plex-sans/400.css'; +import '@fontsource/ibm-plex-sans/500.css'; +import '@fontsource/ibm-plex-sans/600.css'; +import '@fontsource/ibm-plex-mono/400.css'; +import '@fontsource/ibm-plex-mono/500.css'; import './style.css'; import { startApp } from './app'; import { initEngine } from './engine'; diff --git a/web/src/sources.ts b/web/src/sources.ts new file mode 100644 index 0000000..f348cb3 --- /dev/null +++ b/web/src/sources.ts @@ -0,0 +1,41 @@ +import type { PipelineResult } from './pipeline'; +import type { SourceSummary, WorkspaceFile } from './types'; +import { workspaceKey } from './workspace'; + +/** Number of distinct source colours (`--src-0` … `--src-5` in style.css). */ +export const SOURCE_HUES = 6; + +export interface SourceChip { + name: string; + kind: 'file' | 'url'; + hue: number; +} + +export function sourceChips(sources: SourceSummary[]): SourceChip[] { + return sources.map((s, i) => ({ name: s.name, kind: s.kind, hue: i % SOURCE_HUES })); +} + +/** The hue of the source that reads each workspace file, or null if the config doesn't use it. */ +export function fileHues(sources: SourceSummary[], files: WorkspaceFile[]): (number | null)[] { + return files.map((f) => { + const index = sources.findIndex((s) => s.kind === 'file' && workspaceKey(s.path ?? '') === workspaceKey(f.name)); + return index < 0 ? null : index % SOURCE_HUES; + }); +} + +export type RunState = 'ok' | 'warning' | 'error' | 'blocked'; + +export function statusOf(result: PipelineResult): { state: RunState; label: string } { + const kinds = new Set(result.problems.map((p) => p.kind)); + if (kinds.has('blocked')) return { state: 'blocked', label: 'Waiting for permission to fetch' }; + if (result.text === null) { + return { state: 'error', label: kinds.has('config') ? 'Config has errors' : 'Merge failed' }; + } + if (result.problems.length) return { state: 'warning', label: 'Merged with warnings' }; + return { state: 'ok', label: 'Merged' }; +} + +export function problemCountLabel(count: number): string { + if (count === 0) return 'No problems'; + return count === 1 ? '1 problem' : `${count} problems`; +} diff --git a/web/src/style.css b/web/src/style.css index a43496f..14c85c1 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1,41 +1,784 @@ :root { - --bg: #fafaf9; --surface: #ffffff; --text: #1c1917; --muted: #78716c; - --border: #e7e5e4; --accent: #2563eb; --error: #b91c1c; --warning: #a16207; - font-family: system-ui, sans-serif; color-scheme: light; + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f1f3f6; + --ink: #172236; + --muted: #5b6577; + --faint: #8d96a6; + --line: #e2e6ed; + --accent: #2f7d4f; + --accent-ink: #ffffff; + --accent-soft: #e5f2ea; + --danger: #b4232a; + --warning: #9a6200; + --warning-soft: #fdf3dc; + --active-line: #f3f6fa; + --selection: #d6e6dc; + --focus: #2f7d4f66; + + --src-0: #2f7d4f; + --src-1: #2d64c8; + --src-2: #b06a00; + --src-3: #7c4dd8; + --src-4: #c0367a; + --src-5: #0f8a8a; + + --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + --radius: 6px; + color-scheme: light; } + @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { - --bg: #0c0a09; --surface: #1c1917; --text: #f5f5f4; --muted: #a8a29e; - --border: #292524; --accent: #60a5fa; --error: #f87171; --warning: #facc15; + --bg: #0e1520; + --surface: #141d2b; + --surface-2: #1a2537; + --ink: #e6eaf2; + --muted: #8a95a8; + --faint: #5f6b80; + --line: #223047; + --accent: #5cc08a; + --accent-ink: #0b1a12; + --accent-soft: #173326; + --danger: #f2777c; + --warning: #e8b55a; + --warning-soft: #33280f; + --active-line: #18233a; + --selection: #26404a; + --focus: #5cc08a66; + + --src-0: #5cc08a; + --src-1: #6f9df5; + --src-2: #e0a248; + --src-3: #a989f5; + --src-4: #ec7fb3; + --src-5: #4cc4c4; color-scheme: dark; } } -* { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--text); } -button, .button { font: inherit; padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); cursor: pointer; } -.topbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--border); } -.topbar h1 { font-size: 1.05rem; margin: 0 auto 0 0; } -.topbar h1 span { color: var(--muted); font-weight: 400; } -.topbar a { color: var(--accent); } -.notice { margin: 8px 16px 0; padding: 8px 12px; border: 1px solid var(--accent); border-radius: 6px; } -.consent { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; border-color: var(--warning); } -.layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 16px; } -@media (max-width: 900px) { .layout { grid-template-columns: minmax(0, 1fr); } } -h2 { font-size: 0.95rem; margin: 16px 0 8px; } -h2 small, .hint { color: var(--muted); font-weight: 400; font-size: 0.85rem; } -.row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } -.editor { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); max-height: 40vh; overflow: auto; } -.files { list-style: none; padding: 0; margin: 0 0 8px; } -.files li { display: flex; gap: 6px; align-items: center; padding: 2px 0; } -.files li.selected .file-name { border-color: var(--accent); } -.file-name { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.field { display: grid; gap: 4px; margin: 8px 0; } -.field input, #variables input { font: inherit; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); min-width: 0; } -#variables .row input { flex: 1; } -.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 8px; } -.tabs [aria-selected='true'] { border-color: var(--accent); } -#output { margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); overflow: auto; max-height: 75vh; font-size: 0.85rem; } -.problems { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } -.problems li { padding: 8px 12px; border-left: 3px solid var(--error); background: var(--surface); border-radius: 4px; white-space: pre-wrap; } -.problems li.warning { border-left-color: var(--warning); } -#reference { min-height: 60vh; } + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--ink); + font: 14px/1.5 var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +code, +.code { + font-family: var(--font-mono); +} + +a { + color: var(--accent); + text-underline-offset: 2px; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* ---------- controls ---------- */ + +.icon { + width: 16px; + height: 16px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.icon-fill { + fill: currentColor; + stroke: none; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + font: 500 13px var(--font-sans); + cursor: pointer; + white-space: nowrap; +} + +.btn:hover { + border-color: var(--faint); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); +} + +.btn-primary:hover { + border-color: var(--accent); + filter: brightness(1.08); +} + +.btn-small { + height: 26px; + padding: 0 10px; + font-size: 12px; +} + +.icon-btn { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.icon-btn:hover { + background: var(--surface-2); + color: var(--ink); +} + +.select select { + appearance: none; + height: 30px; + min-width: 0; + max-width: 260px; + padding: 0 32px 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238d96a6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3C/svg%3E") + no-repeat right 8px center / 16px; + color: var(--ink); + font: 500 13px var(--font-sans); + cursor: pointer; +} + +/* ---------- layout ---------- */ + +.app { + display: flex; + flex-direction: column; + height: 100dvh; +} + +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--line); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--ink); + text-decoration: none; +} + +.brand-mark { + width: 22px; + height: 22px; + fill: none; + stroke: var(--accent); + stroke-width: 2.4; + stroke-linecap: round; +} + +.brand-mark circle { + fill: var(--accent); + stroke: none; +} + +.brand-name { + font-weight: 600; + font-size: 15px; + letter-spacing: -0.01em; +} + +.tagline { + margin: 0; + padding-left: 16px; + border-left: 1px solid var(--line); + color: var(--muted); + font-size: 13px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.intro { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + background: var(--accent-soft); + border-bottom: 1px solid var(--line); + font-size: 13px; +} + +.intro p { + margin: 0; + max-width: 80ch; +} + +.intro a { + font-weight: 500; + white-space: nowrap; +} + +.intro .icon-btn { + margin-left: auto; +} + +.consent { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: var(--warning-soft); + border-bottom: 1px solid var(--line); + color: var(--ink); + font-size: 13px; +} + +.consent .icon { + color: var(--warning); +} + +.consent .btn { + margin-left: auto; +} + +.intro[hidden], +.consent[hidden] { + display: none; +} + +.workbench { + display: grid; + grid-template-columns: 248px minmax(0, 1fr) minmax(0, 1fr); + flex: 1; + min-height: 0; +} + +.pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--surface); +} + +.pane + .pane { + border-left: 1px solid var(--line); +} + +.sidebar { + background: var(--bg); + overflow-y: auto; +} + +.pane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 40px; + padding: 0 8px 0 16px; + border-bottom: 1px solid var(--line); +} + +.pane-header h2 { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.pane-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.pane-actions[hidden] { + display: none; +} + +/* ---------- files ---------- */ + +.files { + list-style: none; + margin: 0; + padding: 6px; +} + +.file { + position: relative; + display: flex; + align-items: center; + border-radius: var(--radius); +} + +.file:hover { + background: var(--surface-2); +} + +.file.selected { + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); +} + +.file-open { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 0; + background: none; + color: var(--ink); + font: 13px var(--font-mono); + text-align: left; + cursor: pointer; +} + +.file-open span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-actions { + position: absolute; + right: 2px; + display: flex; + border-radius: var(--radius); + background: inherit; + opacity: 0; +} + +.file:hover .file-actions, +.file:focus-within .file-actions { + opacity: 1; +} + +.file-actions .icon-btn { + width: 24px; + height: 24px; +} + +.file-actions .icon { + width: 14px; + height: 14px; +} + +.dot { + width: 8px; + height: 8px; + flex: none; + border-radius: 50%; + background: var(--src, transparent); + box-shadow: inset 0 0 0 1.5px var(--src, var(--faint)); +} + +.hue-0 { --src: var(--src-0); } +.hue-1 { --src: var(--src-1); } +.hue-2 { --src: var(--src-2); } +.hue-3 { --src: var(--src-3); } +.hue-4 { --src: var(--src-4); } +.hue-5 { --src: var(--src-5); } + +.empty { + margin: 4px 16px; + color: var(--muted); + font-size: 13px; +} + +/* ---------- settings ---------- */ + +.settings { + margin-top: auto; + border-top: 1px solid var(--line); + padding: 0 16px 12px; + font-size: 13px; +} + +.settings summary { + padding: 10px 0; + font-weight: 600; + cursor: pointer; +} + +.field { + display: grid; + gap: 4px; + margin-top: 10px; +} + +.field > span { + font-weight: 500; +} + +.field input, +.variables input { + width: 100%; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + font: 12px var(--font-mono); +} + +.hint { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.variables { + display: grid; + gap: 6px; + margin: 6px 0; +} + +.variable { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 4px; + align-items: center; +} + +/* ---------- tabs ---------- */ + +.tabs-header { + padding-left: 8px; +} + +.tabs { + display: flex; + align-self: stretch; + min-width: 0; + overflow-x: auto; +} + +.tabs [role='tab'] { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--muted); + font: 500 13px var(--font-sans); + white-space: nowrap; + cursor: pointer; +} + +.editor-tabs [role='tab'] { + font-family: var(--font-mono); + font-weight: 400; + font-size: 12px; +} + +.tabs [role='tab']:hover { + color: var(--ink); +} + +.tabs [aria-selected='true'] { + border-bottom-color: var(--accent); + color: var(--ink); +} + +.tabs [role='tab'][hidden] { + display: none; +} + +.badge { + min-width: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--danger); + color: var(--surface); + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; +} + +.badge:empty { + display: none; +} + +/* ---------- editor & output ---------- */ + +.editor, +.panel { + flex: 1; + min-height: 0; + overflow: auto; +} + +.editor[hidden], +.panel[hidden] { + display: none; +} + +.code { + margin: 0; + padding: 12px 16px; + color: var(--ink); + font-size: 12.5px; + line-height: 1.6; + white-space: pre; +} + +#reference { + min-height: 100%; +} + +/* Scalar's stylesheet also targets CodeMirror; keep the editors on the page font. */ +.editor .cm-scroller, +.editor .cm-content, +.editor .cm-gutters { + font-family: var(--font-mono) !important; +} + +.problems { + list-style: none; + margin: 0; + padding: 12px; + display: grid; + gap: 8px; +} + +.problem { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); +} + +.problem .icon { + margin-top: 2px; + color: var(--danger); +} + +.problem.warning .icon { + color: var(--warning); +} + +.problem-source { + font: 500 12px var(--font-mono); + color: var(--muted); +} + +.problem-message { + grid-column: 2; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ---------- status bar & toast ---------- */ + +.statusbar { + display: flex; + align-items: center; + gap: 0; + min-height: 28px; + padding: 0 8px; + background: var(--surface); + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 12px; +} + +.statusbar > span:not(.status-spacer):not(:empty) { + padding: 0 10px; +} + +.statusbar > span:not(.status-spacer):not(:empty) + span:not(:empty):not(.status-spacer) { + border-left: 1px solid var(--line); +} + +.status-spacer { + flex: 1; +} + +.status { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink); + font-weight: 500; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); +} + +.status[data-state='warning'] .status-dot, +.status[data-state='blocked'] .status-dot { + background: var(--warning); +} + +.status[data-state='error'] .status-dot { + background: var(--danger); +} + +.status[data-state='busy'] .status-dot { + background: var(--faint); +} + +.status-sources { + display: inline-flex; + flex-wrap: wrap; + gap: 10px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); +} + +.toast { + position: fixed; + right: 16px; + bottom: 44px; + max-width: min(420px, calc(100vw - 32px)); + padding: 10px 14px; + border: 1px solid var(--line); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 8px 24px rgb(15 23 42 / 0.14); + font-size: 13px; +} + +/* ---------- small screens ---------- */ + +@media (max-width: 1100px) { + .tagline { + display: none; + } +} + +@media (max-width: 900px) { + .app { + display: block; + height: auto; + } + + .topbar { + flex-wrap: wrap; + } + + .topbar-actions { + width: 100%; + margin-left: 0; + } + + .select { + flex: 1; + min-width: 0; + } + + .select select { + width: 100%; + max-width: none; + } + + .intro { + flex-wrap: wrap; + } + + .workbench { + display: block; + } + + .pane + .pane { + border-left: 0; + border-top: 1px solid var(--line); + } + + .editor, + .panel { + height: 60vh; + flex: none; + } + + .file-actions { + position: static; + opacity: 1; + } + + .statusbar { + flex-wrap: wrap; + padding: 4px 8px; + } + + .status-note { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} diff --git a/web/src/ui/editor.ts b/web/src/ui/editor.ts index 32311ce..68c0a8e 100644 --- a/web/src/ui/editor.ts +++ b/web/src/ui/editor.ts @@ -2,7 +2,8 @@ import { EditorView, basicSetup } from 'codemirror'; import { EditorState } from '@codemirror/state'; import { yaml } from '@codemirror/lang-yaml'; import { setDiagnostics } from '@codemirror/lint'; -import { oneDark } from '@codemirror/theme-one-dark'; +import { syntaxHighlighting } from '@codemirror/language'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; export interface YamlEditor { /** Replace the document without triggering `onChange`. */ @@ -15,6 +16,24 @@ function prefersDark(): boolean { return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; } +/** Editor chrome drawn from the page's CSS tokens, so it follows light and dark mode. */ +const chrome = EditorView.theme({ + '&': { height: '100%', fontSize: '12.5px', backgroundColor: 'var(--surface)', color: 'var(--ink)' }, + '&.cm-focused': { outline: 'none' }, + '.cm-scroller': { fontFamily: 'var(--font-mono)', lineHeight: '1.6' }, + '.cm-content': { caretColor: 'var(--ink)', paddingBlock: '8px' }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--ink)' }, + '.cm-gutters': { backgroundColor: 'var(--surface)', color: 'var(--faint)', border: 'none' }, + '.cm-lineNumbers .cm-gutterElement': { paddingInline: '12px 8px' }, + '.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'var(--active-line)' }, + '.cm-activeLineGutter': { color: 'var(--muted)' }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection': + { backgroundColor: 'var(--selection) !important' }, + '.cm-foldGutter .cm-gutterElement': { color: 'var(--faint)' }, + '.cm-tooltip': { backgroundColor: 'var(--surface)', border: '1px solid var(--line)', borderRadius: '6px' }, + '.cm-diagnostic-error': { borderLeftColor: 'var(--danger)' }, +}); + export function createYamlEditor(parent: HTMLElement, doc: string, onChange: (text: string) => void): YamlEditor { let silent = false; const view = new EditorView({ @@ -24,7 +43,8 @@ export function createYamlEditor(parent: HTMLElement, doc: string, onChange: (te extensions: [ basicSetup, yaml(), - ...(prefersDark() ? [oneDark] : []), + chrome, + ...(prefersDark() ? [syntaxHighlighting(oneDarkHighlightStyle)] : []), EditorView.updateListener.of((update) => { if (update.docChanged && !silent) onChange(update.state.doc.toString()); }), diff --git a/web/src/ui/reference.ts b/web/src/ui/reference.ts index c958774..9288830 100644 --- a/web/src/ui/reference.ts +++ b/web/src/ui/reference.ts @@ -10,7 +10,16 @@ let shown = ''; async function load(el: HTMLElement): Promise { const { createApiReference } = await import('@scalar/api-reference'); await import('@scalar/api-reference/style.css'); - app = createApiReference(el, { content: latest }) as unknown as ReferenceApp; + app = createApiReference(el, { + content: latest, + showDeveloperTools: 'never', + agent: { disabled: true }, + telemetry: false, + withDefaultFonts: false, + darkMode: globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false, + hideDarkModeToggle: true, + customCss: '.scalar-app { --scalar-font: var(--font-sans); --scalar-font-code: var(--font-mono); }', + }) as unknown as ReferenceApp; shown = latest; } diff --git a/web/tests/sources.test.ts b/web/tests/sources.test.ts new file mode 100644 index 0000000..6451b9b --- /dev/null +++ b/web/tests/sources.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { fileHues, problemCountLabel, sourceChips, statusOf } from '../src/sources'; +import type { PipelineResult } from '../src/pipeline'; +import type { SourceSummary } from '../src/types'; + +const file = (name: string, path: string): SourceSummary => ({ name, kind: 'file', path, url: null, headers: {} }); +const url = (name: string): SourceSummary => ({ name, kind: 'url', path: null, url: 'https://x.test/a', headers: {} }); + +describe('source colours', () => { + const sources = [file('inventory', './specs/inventory.yaml'), url('petstore'), file('catalog', 'specs/catalog.yaml')]; + + it('gives each source a hue by position, cycling after six', () => { + expect(sourceChips(sources)).toEqual([ + { name: 'inventory', kind: 'file', hue: 0 }, + { name: 'petstore', kind: 'url', hue: 1 }, + { name: 'catalog', kind: 'file', hue: 2 }, + ]); + const many = Array.from({ length: 7 }, (_, i) => file(`s${i}`, `s${i}.yaml`)); + expect(sourceChips(many)[6].hue).toBe(0); + }); + + it('colours workspace files by the source that uses them, null when unused', () => { + const files = [ + { name: 'specs/catalog.yaml', content: '' }, + { name: 'notes.yaml', content: '' }, + { name: 'specs/inventory.yaml', content: '' }, + ]; + expect(fileHues(sources, files)).toEqual([2, null, 0]); + }); +}); + +describe('status', () => { + const result = (over: Partial): PipelineResult => ({ text: 'x', format: 'yaml', problems: [], ...over }); + + it('describes the outcome of the last run', () => { + expect(statusOf(result({}))).toEqual({ state: 'ok', label: 'Merged' }); + expect(statusOf(result({ problems: [{ level: 'warning', kind: 'merge', message: 'w' }] }))).toEqual({ + state: 'warning', + label: 'Merged with warnings', + }); + expect(statusOf(result({ text: null, problems: [{ level: 'error', kind: 'config', message: 'c' }] }))).toEqual({ + state: 'error', + label: 'Config has errors', + }); + expect(statusOf(result({ text: null, problems: [{ level: 'error', kind: 'merge', message: 'm' }] }))).toEqual({ + state: 'error', + label: 'Merge failed', + }); + expect(statusOf(result({ text: null, problems: [{ level: 'error', kind: 'blocked', message: 'b' }] }))).toEqual({ + state: 'blocked', + label: 'Waiting for permission to fetch', + }); + }); + + it('counts problems in words', () => { + expect(problemCountLabel(0)).toBe('No problems'); + expect(problemCountLabel(1)).toBe('1 problem'); + expect(problemCountLabel(3)).toBe('3 problems'); + }); +});