diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376ba6..d8b33ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ and this project adheres to ### Fixed +- Support Home and End keys in the Access tokens base URL segmented control, + skipping disabled options (#129). + +- Stop dashboard Public URL polling when leaving Settings and offer Refresh when + the background check outlasts polling (#129). + +- Cancel Public URL probes during plugin cleanup and ignore late settings writes + after disposal, including when the plugin is re-registered (#129). + +- Return immediately after saving a Public URL while serialized background + checks keep only the latest matching result. Refresh dashboard check results + and OAuth warnings when checks finish, and match persistent warning text in + the editor sidebar (#129). + +- Skip the Public URL probe when clearing the URL and clear persisted check + metadata so stale failures are removed (#129). +- Persist Public URL probe results only for the current validated settings + origin, preserving saved failures and OAuth warnings when other URLs are + checked (#129). + +- Use thin theme-accent focus rings only for keyboard focus on dashboard + navigation, details summaries, and buttons. - Recover dashboard editor jumps when the last-focused editor tab has closed or stopped responding. Track editor presence, clear owned focus and presence on pagehide, and avoid reloading a live editor when acknowledgement times out. @@ -27,6 +49,22 @@ and this project adheres to prereleases in `release.yml` (#128). - Include a generic install URL and install command in prerelease notes (#128). +### Added + +- Require a configured Public URL for the MCP OAuth server URL, add a shortcut + to its Settings field, and show OAuth discovery warnings for missing URLs or + matching persisted failed checks in the dashboard and editor sidebar. +- Automatically check changed Public URLs using the existing guarded probe and + persist check metadata separately from editable settings. +- Add an Access tokens header segmented control for link and setup base URLs, + defaulting to this page's URL. Store the preference only in browser + localStorage and disable Public URL selection until configured. + +### Changed + +- Make the dashboard and editor sidebar MCP OAuth controls accessible toggle + switches. + ## [0.2.0-beta.13] - 2026-11-10 - Label the "Opened from dashboard" card action Go to flow for flow, tab and diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 341c02a..993090a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -437,7 +437,12 @@ paths: post: tags: [Status] summary: Check the configured public URL - description: Probes the MCP endpoint at the supplied HTTPS origin without following redirects. + description: >- + Probes the MCP endpoint at the supplied HTTPS origin without following redirects. + Persists the latest result, including failures, as internal publicUrlCheck metadata. + PUT /settings also runs this check when publicBaseUrl changes. Stored results + apply only when their exact publicBaseUrl matches the current setting. + Clients cannot overwrite this metadata through PUT /settings. operationId: checkPublicUrl security: - AdminSession: [] @@ -463,6 +468,7 @@ paths: ok: { type: boolean } status: { type: [integer, 'null'] } url: { type: string } + publicUrlCheck: { $ref: '#/components/schemas/PublicUrlCheck' } error: { type: string } '400': $ref: '#/components/responses/BadRequest' @@ -1945,6 +1951,22 @@ components: format: uri additionalProperties: false + PublicUrlCheck: + type: object + description: Internal persisted check metadata, not an editable or settings.js-lockable setting. + required: [publicBaseUrl, status, ok, checkedAt] + properties: + publicBaseUrl: + type: string + description: Exact Public URL value checked. Ignore the result for other URL values. + status: + type: [integer, 'null'] + description: HTTP status, or null for network, timeout, or validation failures. + ok: + type: boolean + checkedAt: + type: string + format: date-time AiAccessSettingsResponse: type: object required: @@ -1971,6 +1993,17 @@ components: type: string apiBase: type: string + publicUrlCheck: + oneOf: + - $ref: '#/components/schemas/PublicUrlCheck' + - type: object + required: [publicBaseUrl, pending] + properties: + publicBaseUrl: { type: string } + pending: { type: boolean, const: true } + - type: 'null' + description: >- + Latest persisted check, null before any check or when cleared, or {publicBaseUrl, pending: true} while an automatic check is queued or running. PUT persists and returns without waiting for the probe. Poll GET until a non-pending check matches the current Public URL. Checks run serially; superseded automatic checks are skipped and superseded results are ignored. settings: type: object required: diff --git a/src/plugin/editor-sidebar.ts b/src/plugin/editor-sidebar.ts index c68526e..33d41b7 100644 --- a/src/plugin/editor-sidebar.ts +++ b/src/plugin/editor-sidebar.ts @@ -19,6 +19,36 @@ function editorSidebarStyles(): string { flex-direction: column; box-sizing: border-box; } + +#red-ui-sidebar-ai-access #ai-access-oauth-enabled { + appearance: none; + box-sizing: border-box; + display: inline-block; + position: relative; + width: 2.4rem; + height: 1.35rem; + flex: 0 0 auto; + border: 1px solid var(--red-ui-form-input-border-color, #888); + border-radius: 1rem; + background: var(--red-ui-form-input-border-color, #888); + cursor: pointer; + vertical-align: middle; +} +#red-ui-sidebar-ai-access #ai-access-oauth-enabled::before { + content: ''; + position: absolute; + top: 0.15rem; + left: 0.15rem; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: var(--red-ui-primary-background, #fff); +} +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:checked { background: var(--red-ui-text-color-link, #4682b4); border-color: var(--red-ui-text-color-link, #4682b4); } +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:checked::before { left: calc(100% - 1.05rem); } +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:focus-visible { outline: 2px solid var(--red-ui-form-input-border-selected-color, #4682b4); outline-offset: 3px; } +#red-ui-sidebar-ai-access #ai-access-oauth-enabled:disabled { opacity: 0.5; cursor: not-allowed; } + #red-ui-sidebar-ai-access .red-ui-sidebar-header { text-align: left; font-weight: 600; @@ -326,7 +356,13 @@ function editorSidebarScriptPartA(): string { $('#ai-access-local-row').toggle(!ADMIN_AUTH); var oauth = s.oauth || {}; + var check = data.publicUrlCheck; + var publicUrlFailed = check && check.publicBaseUrl === s.publicBaseUrl && check.ok === false; + $('#ai-access-oauth-public-url-warning') + .text(!s.publicBaseUrl ? 'Set a Public URL so OAuth clients can discover this server.' : 'The latest Public URL check failed. OAuth clients may not discover this server. Check the Public URL in Settings.') + .toggle(!!oauth.enabled && (!s.publicBaseUrl || publicUrlFailed)); $('#ai-access-oauth-enabled').prop('checked', !!oauth.enabled) + .attr('aria-checked', String(!!oauth.enabled)) .prop('disabled', readonly || sources.oauth === 'settings.js'); $('#ai-access-readonly-note').toggle(readonly); $('#ai-access-lock-enabled').toggle(sources.enabled === 'settings.js'); @@ -396,6 +432,7 @@ function editorSidebarScriptPartA(): string { } function oauthPublicUrlWarning(value) { + if (LAST && LAST.publicUrlCheck && LAST.publicUrlCheck.publicBaseUrl === value && LAST.publicUrlCheck.ok === false) return 'The latest Public URL check failed. OAuth clients may not discover this server.'; if (!value) return 'No Public URL is configured. OAuth clients may not discover this server.'; try { var url = new URL(value); @@ -446,7 +483,7 @@ function editorSidebarScriptPartA(): string { if (warning) { openConfirmDialog(warning + '\\n\\nEnable OAuth anyway? (Set the Public URL on the AI Access dashboard.)').then(function (accepted) { if (accepted) persistSettings(payload); - else $('#ai-access-oauth-enabled').prop('checked', false); + else $('#ai-access-oauth-enabled').prop('checked', false).attr('aria-checked', 'false'); }); return; } @@ -530,13 +567,14 @@ function editorSidebarScriptPartB(): string { // (2) Enable MCP OAuth. row = $('
').appendTo(body); - $('').appendTo(row); + $('').appendTo(row); $('') .html('

Enable MCP OAuth

MCP clients (ChatGPT, Claude, …) sign in at <Public URL>/mcp.') .appendTo(row); $('') .text('🔒 settings.js').appendTo(row); + $('').appendTo(body); // (3) OAuth route conflict warning — only rendered when conflicts exist. $('').appendTo(body); @@ -545,6 +583,15 @@ function editorSidebarScriptPartB(): string { saveSettings ); + $('#ai-access-oauth-enabled').on('change', function () { + $('#ai-access-oauth-enabled').attr('aria-checked', String($('#ai-access-oauth-enabled').is(':checked'))); + }).on('keydown', function (ev) { + if (ev.key !== 'Enter') return; + ev.preventDefault(); + var control = $('#ai-access-oauth-enabled'); + if (!control.prop('disabled') && !ev.repeat) control[0].click(); + }); + // (4) Open the dashboard. row = $('
').appendTo(body); $('') diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 77efbf8..e2d0e6b 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -86,6 +86,7 @@ import { aiRedactingAdapter, rawFlowAdapter } from './secret-redaction.js'; import { createPluginServices, type CreatePluginServicesOptions } from './services.js'; import { checkPublicUrlHandler, + disposePublicUrlChecks, getSettingsHandler, putSettingsHandler, } from './settings-routes.js'; @@ -203,6 +204,7 @@ export function registerRoutes( recordLiveSnapshot(rawFlowAdapter(services.flowAdapter), event.config); }; const shutdown = () => { + disposePublicUrlChecks(services.settingsStore); try { oauthStore.dispose(); } catch (error) { @@ -499,7 +501,7 @@ export function registerRoutes( requireAdminSession(RED, 'api'), requireStarPermission(RED), requireJsonContentType('rest'), - asyncRoute(checkPublicUrlHandler(RED)) + asyncRoute(checkPublicUrlHandler(RED, services.settingsStore)) ); const linkGuards = [ diff --git a/src/plugin/page-client-settings.ts b/src/plugin/page-client-settings.ts index de4fec9..584dc7a 100644 --- a/src/plugin/page-client-settings.ts +++ b/src/plugin/page-client-settings.ts @@ -52,7 +52,8 @@ function settingsPartForm(): string { function mcpServerUrl() { var s = (settingsData && settingsData.settings) || {}; - var base = s.publicBaseUrl ? String(s.publicBaseUrl).replace(/\\/$/, '') : location.origin; + var base = s.publicBaseUrl ? String(s.publicBaseUrl).replace(/\\/$/, '') : ''; + if (!base) return ''; return base + mcpRpc.replace(/\\/rpc$/, ''); } @@ -93,6 +94,7 @@ function settingsPartForm(): string { var o = s.oauth || {}; var oauthLocked = fieldLocked('oauth'); $('oauth-enabled').checked = !!o.enabled; + syncOAuthSwitch(); setDurationInputs('oauth-access-ttl', o.accessTokenTtlSec || 3600, OAUTH_ACCESS_UNITS); setDurationInputs('oauth-refresh-ttl', o.refreshIdleTtlSec || 2592000, OAUTH_REFRESH_UNITS); $('oauth-refresh-max').value = String(Math.round((typeof o.refreshMaxLifetimeSec === 'number' ? o.refreshMaxLifetimeSec : 7776000) / 86400)); @@ -100,7 +102,19 @@ function settingsPartForm(): string { ['oauth-enabled', 'oauth-access-ttl', 'oauth-access-ttl-unit', 'oauth-refresh-ttl', 'oauth-refresh-ttl-unit', 'oauth-refresh-max', 'oauth-dcr-cap'].forEach(function (id) { $(id).disabled = ro || oauthLocked; }); $('lock-oauth').hidden = !oauthLocked; - $('oauth-server-url').textContent = mcpServerUrl(); + var serverUrl = mcpServerUrl(); + $('oauth-server-url').textContent = serverUrl || 'Set a Public URL to get the MCP server URL'; + $('oauth-copy-url').disabled = !serverUrl; + var check = data.publicUrlCheck; + if (check && check.publicBaseUrl === s.publicBaseUrl) renderPublicUrlCheck(check); + else setCheckResult('', ''); + var failed = check && check.publicBaseUrl === s.publicBaseUrl && check.ok === false; + var warning = !!o.enabled && (!s.publicBaseUrl || failed); + $('oauth-public-url-warning').textContent = !s.publicBaseUrl + ? 'Set a Public URL so OAuth clients can discover this server.' + : 'The latest Public URL check failed. OAuth clients may not discover this server. Check the Public URL in Settings.'; + $('oauth-public-url-warning').hidden = !warning; + if (typeof updateTokenUrlBase === 'function') updateTokenUrlBase(); } function oauthEnableWarning(value) { @@ -250,11 +264,57 @@ function settingsPartSave(): string { else showFlash($(target), msg, false); } + var publicUrlPollGeneration = 0; + var publicUrlPollWake = null; + function cancelPublicUrlPoll() { + publicUrlPollGeneration++; + if (publicUrlPollWake) publicUrlPollWake(); + } + function publicUrlPollActive(generation) { + var tab = document.querySelector('nav.tabs button[aria-current="page"]'); + return generation === publicUrlPollGeneration && tab && tab.getAttribute('data-tab') === 'settings'; + } + async function pollPublicUrlCheck(url, generation) { + for (var i = 0; i < 40; i++) { + if (!publicUrlPollActive(generation)) return; + await new Promise(function (resolve) { + var timer = setTimeout(function () { publicUrlPollWake = null; resolve(); }, 250); + publicUrlPollWake = function () { clearTimeout(timer); publicUrlPollWake = null; resolve(); }; + }); + if (!publicUrlPollActive(generation)) return; + try { + var data = await api('/settings'); + if (!publicUrlPollActive(generation) || !settingsData || settingsData.settings.publicBaseUrl !== url) return; + if (data.settings.publicBaseUrl !== url) return; + var check = data.publicUrlCheck; + if (check && check.publicBaseUrl === url && !check.pending) { + settingsData.publicUrlCheck = check; + renderPublicUrlCheck(check); + $('oauth-public-url-warning').hidden = !(settingsData.settings.oauth || {}).enabled || check.ok !== false; + return; + } + } catch (e) { /* Retry transient failures until timeout. */ } + } + if (publicUrlPollActive(generation)) { + setCheckResult('pending', 'Check still running — '); + var refresh = document.createElement('button'); + refresh.type = 'button'; + refresh.textContent = 'Refresh'; + refresh.addEventListener('click', loadSettings); + $('set-check-result').appendChild(refresh); + } + } + async function persistSettings(payload) { + cancelPublicUrlPoll(); + var pollGeneration = publicUrlPollGeneration; settingsSaving = true; try { var data = await api('/settings', { method: 'PUT', body: JSON.stringify(payload) }); applySettings(data); + if (data.publicUrlCheck && data.publicUrlCheck.pending) { + void pollPublicUrlCheck(data.settings.publicBaseUrl, pollGeneration); + } var warn = data.warnings && data.warnings.length ? data.warnings.join(' ') : ''; $('set-public-url-warning').textContent = warn; $('set-public-url-warning').hidden = !warn; @@ -293,11 +353,12 @@ function settingsPartSave(): string { var warning = oauthEnableWarning(url); if (warning) { $('oauth-enabled').checked = false; + syncOAuthSwitch(); openConfirmDialog({ title: 'Enable MCP OAuth?', message: warning + ' Enable OAuth anyway?', confirmLabel: 'Enable anyway', - onConfirm: function () { $('oauth-enabled').checked = true; persistSettings(payload); } + onConfirm: function () { $('oauth-enabled').checked = true; syncOAuthSwitch(); persistSettings(payload); } }); return; } @@ -331,15 +392,23 @@ function settingsPartSave(): string { el.setAttribute('role', state === 'fail' ? 'alert' : 'status'); } + function renderPublicUrlCheck(r) { + if (r.pending) { setCheckResult('pending', 'Checking…'); return; } + setCheckResult(r.ok ? 'ok' : 'fail', (r.ok ? '✓ Reachable: ' : '✗ Failed: ') + + (r.message || r.error || (r.ok ? 'Public URL is reachable.' : 'Public URL check failed.')) + + (r.status ? ' (HTTP ' + r.status + ')' : '')); + } + async function checkPublicUrl() { setCheckResult('pending', 'Checking…'); try { var r = await api('/settings/check-public-url', { method: 'POST', body: JSON.stringify({ publicBaseUrl: $('set-public-url').value }) }); - setCheckResult(r.ok ? 'ok' : 'fail', (r.ok ? '✓ Reachable: ' : '✗ Failed: ') + (r.message || r.error || '') + - (r.status ? ' (HTTP ' + r.status + ')' : '')); + await loadSettings(); + renderPublicUrlCheck(r); } catch (e) { + await loadSettings(); setCheckResult('fail', '✗ ' + (e.message || String(e))); } } @@ -479,7 +548,17 @@ function settingsPartWire(): string { loadOAuthClients(); } + function syncOAuthSwitch() { + $('oauth-enabled').setAttribute('aria-checked', String($('oauth-enabled').checked)); + } + (function wireSettings() { + $('oauth-enabled').addEventListener('change', syncOAuthSwitch); + $('oauth-enabled').addEventListener('keydown', function (ev) { + if (ev.key !== 'Enter') return; + ev.preventDefault(); + if (!$('oauth-enabled').disabled && !ev.repeat) $('oauth-enabled').click(); + }); var sec = $('section-settings'); var oauthSec = $('section-oauth'); [sec, oauthSec].forEach(function (host) { @@ -500,8 +579,19 @@ function settingsPartWire(): string { }); $('set-secret-reset').addEventListener('click', resetSecretPatterns); $('oauth-refresh').addEventListener('click', function () { loadOAuthStatus(); loadOAuthClients(); }); + $('oauth-public-url-settings').addEventListener('click', function () { + selectTab('settings'); + var focusPublicUrl = function () { + $('set-public-url').focus(); + $('set-public-url').scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + if (window.requestAnimationFrame) window.requestAnimationFrame(focusPublicUrl); + else focusPublicUrl(); + }); $('oauth-copy-url').addEventListener('click', function () { - copyTextToClipboard($('oauth-server-url').textContent || ''); + var url = mcpServerUrl(); + if (!url) return; + copyTextToClipboard(url); showToast('Server URL copied.', true); }); })(); diff --git a/src/plugin/page-client.ts b/src/plugin/page-client.ts index 0f18686..f822970 100644 --- a/src/plugin/page-client.ts +++ b/src/plugin/page-client.ts @@ -820,6 +820,7 @@ function pageClientScriptPartATabs(): string { function selectTab(target, options) { options = options || {}; if (!target) return; + if (target !== 'settings') cancelPublicUrlPoll(); document.querySelectorAll('nav.tabs button').forEach(function (b) { b.setAttribute('aria-current', b.getAttribute('data-tab') === target ? 'page' : 'false'); }); @@ -853,7 +854,7 @@ function pageClientScriptPartATabs(): string { function loadTabData(target) { if (target === 'settings') loadSettings(); if (target === 'oauth') loadOAuthTab(); - if (target === 'links') loadLinks(); + if (target === 'links') { loadLinks(); loadSettings(); } if (target === 'proposals') loadProposals(); if (target === 'logs') { // Load proposals so older audit rows can resolve titles / flow labels. @@ -1234,8 +1235,54 @@ function pageClientScriptPartB(): string { }; } + var TOKEN_URL_BASE_KEY = 'ai-access.token-url-base'; + function tokenUrlBaseChoice() { + try { return localStorage.getItem(TOKEN_URL_BASE_KEY) === 'public' ? 'public' : 'page'; } + catch (e) { return 'page'; } + } + function tokenPublicBase() { + return settingsData && settingsData.settings && settingsData.settings.publicBaseUrl || ''; + } + function updateTokenUrlBase() { + if (!$('token-url-base')) return; + var available = !!tokenPublicBase(); + var page = $('token-url-base-page'); + var publicButton = $('token-url-base-public'); + publicButton.disabled = !available; + publicButton.setAttribute('aria-disabled', String(!available)); + publicButton.title = available ? '' : 'Set a Public URL in Settings to use it here.'; + var choice = available ? tokenUrlBaseChoice() : 'page'; + if (!available) { + try { localStorage.setItem(TOKEN_URL_BASE_KEY, 'page'); } catch (e) { /* storage unavailable */ } + } + [page, publicButton].forEach(function (button, index) { + var value = index === 0 ? 'page' : 'public'; + button.setAttribute('aria-checked', String(choice === value)); + button.tabIndex = choice === value ? 0 : -1; + button.onclick = function () { + if (button.disabled) return; + try { localStorage.setItem(TOKEN_URL_BASE_KEY, value); } + catch (e) { /* storage unavailable */ } + updateTokenUrlBase(); + }; + button.onkeydown = function (event) { + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].indexOf(event.key) === -1) return; + event.preventDefault(); + var enabled = [page, publicButton].filter(function (option) { return !option.disabled; }); + if (!enabled.length) return; + var next = event.key === 'Home' ? enabled[0] + : event.key === 'End' ? enabled[enabled.length - 1] + : enabled[(enabled.indexOf(button) + (event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1) + enabled.length) % enabled.length]; + next.click(); + next.focus(); + }; + }); + renderTokenUsage(); + } + function linkCopyMaterial(kind, token, url) { var origin = (typeof location !== 'undefined' && location.origin) ? location.origin : ''; + if (tokenUrlBaseChoice() === 'public' && tokenPublicBase()) origin = tokenPublicBase().replace(/\\/$/, ''); var base = origin + apiBase; var rpc = origin + mcpRpc; var k = normalizeLinkKind(kind); @@ -1281,9 +1328,9 @@ function pageClientScriptPartB(): string { mcpSnippets: snippets }; } - var getLink = url || (tok + var getLink = tok ? (origin + (apiBase.replace(/\\/mcp\\/api\\/v1\\/?$/, '/mcp/a/') + tok)) - : ''); + : (url ? origin + new URL(url, location.origin).pathname : ''); return { kind: 'get_link', label: 'GET link (chat paste)', @@ -3776,6 +3823,7 @@ function pageClientScriptPartDBoot(): string { var initialTab = initialTabFromHash(location.hash || ''); selectTab(initialTab, { skipHash: !!readProposalHashId(), focusHeading: !!location.hash }); if (initialTab === 'settings') loadSettings(); + if (initialTab === 'links') loadSettings(); if (initialTab === 'oauth') loadOAuthTab(); if (aiOn) { if (canManage) loadLinks(); diff --git a/src/plugin/page-styles.ts b/src/plugin/page-styles.ts index a2a955a..bd753b4 100644 --- a/src/plugin/page-styles.ts +++ b/src/plugin/page-styles.ts @@ -131,7 +131,9 @@ nav.tabs button { } nav.tabs button svg { flex: 0 0 16px; } nav.tabs button:hover { background: var(--soft); } -nav.tabs button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* Suppress pointer-focus defaults; retain a consistent keyboard indicator. */ +.settings-layout :is(button, summary, [role="button"]):focus { outline: none; } +.settings-layout :is(button, summary, [role="button"]):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } nav.tabs button[aria-current="page"] { background: var(--accent-soft); font-weight: 600; } nav.tabs button[aria-current="page"]::before { content: ""; @@ -144,7 +146,7 @@ nav.tabs button[aria-current="page"]::before { background: var(--accent); } .settings-layout > section.panel { grid-column: 2; grid-row: 1; min-width: 0; } -section.panel > h2 { +section.panel h2 { padding-bottom: 0.65rem; border-bottom: 1px solid var(--line); font-size: 1.65rem; @@ -188,12 +190,23 @@ section.panel { padding: 1.15rem 1.25rem 1.35rem; } section.panel.active { display: block; } -section.panel > h2 { +section.panel h2 { margin: 0 0 0.75rem; font-size: 1.65rem; } -section.panel > h2:focus { outline: none; } -section.panel > h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +section.panel h2:focus { outline: none; } +section.panel h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.panel-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; } +section.panel .panel-header h2 { margin: 0; } +.segmented-control { display: inline-flex; margin-left: auto; } +.segmented-control button { font: inherit; font-size: 0.85rem; padding: 0.4rem 0.65rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; } +.segmented-control button + button { border-left: 0; } +.segmented-control button:first-child { border-radius: 7px 0 0 7px; } +.segmented-control button:last-child { border-radius: 0 7px 7px 0; } +.segmented-control button[aria-checked="true"] { background: var(--accent); color: #fff; } +.segmented-control button:disabled { opacity: 0.5; cursor: not-allowed; } +.segmented-control button:focus-visible { position: relative; outline: 2px solid var(--accent); outline-offset: 2px; } + .warning { background: var(--warn-bg); border: 1px solid var(--warn-border); @@ -869,11 +882,7 @@ button.id-copy:focus .id-short { text-decoration: underline; text-underline-offset: 2px; } -button.id-copy:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: 3px; -} +button.id-copy:focus-visible { border-radius: 3px; } .filter-chip { border: 1px solid var(--line); background: var(--card); @@ -1268,6 +1277,36 @@ section.panel { scroll-margin-top: 1rem; } .hint { display: block; color: var(--muted); font-size: 0.88rem; font-weight: 400; margin: 0.25rem 0 0; line-height: 1.4; } .warn-text { color: #8a5a00; } .toggle-row { display: flex; align-items: flex-start; gap: 0.5rem; justify-content: space-between; } + +#oauth-enabled { + appearance: none; + box-sizing: border-box; + display: inline-block; + position: relative; + width: 2.4rem; + height: 1.35rem; + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--line); + cursor: pointer; + vertical-align: middle; +} +#oauth-enabled::before { + content: ''; + position: absolute; + top: 0.15rem; + left: 0.15rem; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: var(--card); +} +#oauth-enabled:checked { background: var(--accent); border-color: var(--accent); } +#oauth-enabled:checked::before { left: calc(100% - 1.05rem); } +#oauth-enabled:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +#oauth-enabled:disabled { opacity: 0.5; cursor: not-allowed; } + .switch-label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; } .switch-label input { margin-top: 0.25rem; width: 1.05rem; height: 1.05rem; flex: 0 0 auto; } .lock-badge { diff --git a/src/plugin/page.ts b/src/plugin/page.ts index 697ff71..9bbfcd8 100644 --- a/src/plugin/page.ts +++ b/src/plugin/page.ts @@ -192,7 +192,13 @@ function renderMcpPageStatusSection(): string { function renderMcpPageLinksSection(): string { return `