From 83644400813c859d59dd9cbdb6794cc618d6f838 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 19 Mar 2026 10:21:40 +0000 Subject: [PATCH] Changeset 0.31.1 (#204) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 34 ++ VERSION | 2 +- docs/ROADMAP.md | 59 ++- packages/dashboard/css/main.css | 197 +++++++++ packages/dashboard/js/main.js | 414 ++++++++++++++++++ packages/dashboard/manifest.json | 14 + packages/editor/js/main.js | 243 +--------- .../js/crud/dashboard-package.js | 122 ++++++ packages/icd-test-runner/js/main.js | 1 + packages/icd-test-runner/js/tier-crud.js | 3 +- .../pages/templates/components/user-menu.html | 2 +- src/css/layout.css | 21 +- src/css/primitives.css | 24 +- src/css/user-menu.css | 86 +--- src/js/chat-pane.js | 253 ++++++++++- src/js/switchboard-sdk.js | 38 +- src/js/user-menu.js | 12 +- 17 files changed, 1186 insertions(+), 339 deletions(-) create mode 100644 packages/dashboard/css/main.css create mode 100644 packages/dashboard/js/main.js create mode 100644 packages/dashboard/manifest.json create mode 100644 packages/icd-test-runner/js/crud/dashboard-package.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a97b8..1eb7f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [0.31.1] — 2026-03-18 + +### Summary + +SDK Exercise Surface: fixes 4 SDK bugs (flyout unification, overflow +clipping, menu system split, ChatPane standalone gap), then proves them +fixed with a dashboard package exercising every `sw.*` primitive with +zero component CSS overrides. + +### Fixed + +- **Flyout unification** — 3 competing CSS systems (layout.css, + user-menu.css, primitives.css) consolidated into `.sw-menu-flyout` + in primitives.css. UserMenu now shares the same flyout class and + `data-position` attribute as `sw.menu()`. +- **Flyout positioning** — `position:fixed` escape hatch for flyouts + inside `overflow:hidden` extension surface containers. Shared + `_positionFlyout` helper wired into both `sw.menu()` and + `sw.userMenu()`. +- **ChatPane standalone** — `ChatPane.mount()` with `standalone: true` + now provides complete chat (textarea, model selector, history, + streaming, channel creation). Editor package slimmed ~220 lines. +- **Flyout text contrast** — menu items used `--text-2` (#9898a8) + against `--bg-elevated` (#42424e), giving ~2.8:1 contrast ratio. + Bumped to `--text` (#e8e8ed) for ~5.5:1 (WCAG AA compliant). + +### New + +- **Dashboard `.pkg`** — `packages/dashboard/` exercises all 14 SDK + primitives: userMenu, menu, toolbar, tabs, dropdown, chat, notes, + modal, confirm, toast, on/emit, api, user/isAdmin, theme. +- **E2E tests** — `crud/dashboard-package.js` — install, settings, + export/import round-trip (7 tests). + ## [0.31.0] — 2026-03-18 ### Summary diff --git a/VERSION b/VERSION index c415e1c..f176c94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.31.2 +0.31.1 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 38f127c..0dcec7e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,10 +38,12 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ v0.30.1 SDK Adoption ✅ │ v0.30.2 Workflow Packages ✅ │ v0.31.0 Editor + SDK ✅ │ + v0.31.1 SDK Exercise ✅ │ + v0.31.2 Team Workflows │ │ │ ══════╪═══════════════════════════════╪══════ │ MVP v0.50.0 │ - │ (v0.31.0 + v0.34.0 │ + │ (v0.31.2 + v0.34.0 │ │ + mobile + deployment docs) │ ══════╪═══════════════════════════════╪══════ │ @@ -284,8 +286,55 @@ Depends on: v0.30.2. - [x] NotePanel: pagination, select mode, sticky selection bar, `note-panel-root` class (no PanelRegistry conflict) **Known remaining (visual polish, not blocking):** -- [ ] UserMenu flyout contrast on very dark backgrounds (functional, low contrast) -- [ ] Editor chat pane duplicates streaming/model-selector logic (~250 lines) — should move to ChatPane or SDK +- [x] UserMenu flyout contrast on very dark backgrounds — fixed in v0.31.1 CS0+CS1 +- [x] Editor chat pane duplicates streaming/model-selector logic — absorbed into ChatPane in v0.31.1 CS2 + + +### v0.31.1 — SDK Exercise Surface ✅ + +Build a dashboard surface package exercising every `sw.*` primitive +with zero component CSS overrides. Fix 4 SDK bugs first. + +Depends on: v0.31.0. + +**SDK Bug Fixes (CS0–CS2):** +- [x] Flyout unification — 3 competing CSS systems consolidated into `.sw-menu-flyout` in primitives.css +- [x] Flyout positioning — `position:fixed` escape hatch for overflow-clipped extension surfaces +- [x] Menu unification — UserMenu uses `.sw-menu-flyout` + `data-position`, same as `sw.menu()` +- [x] ChatPane self-contained — standalone mode with streaming, model selector, history (editor slimmed ~220 lines) + +**Dashboard Package (CS3–CS4):** +- [x] `packages/dashboard/` — type "full", route `/s/dashboard` +- [x] Exercises all 14 primitives: userMenu, menu, toolbar, tabs, dropdown, chat, notes, modal, confirm, toast, on/emit, api, user/isAdmin, theme +- [x] Layout-only CSS — zero references to SDK component classes +- [x] E2E tests: install, settings, export/import round-trip (7 tests) + +### v0.31.2 — Team Workflow Self-Service + +Close the gap: team admins can create and manage workflows for their +team without requiring platform admin access. Backend routes exist +(`/workflows` with `team_id`), but no team-scoped routes or UI. + +Depends on: v0.31.1. + +**CS0 — Backend: Team-Scoped Workflow Routes:** +- [ ] `/teams/:teamId/workflows` route group behind `RequireTeamAdmin` +- [ ] CRUD: GET (list), POST (create, inject team_id), PATCH (update), DELETE +- [ ] Stage CRUD: GET, POST, PUT, DELETE, PATCH reorder — scoped to team workflows +- [ ] Publish: `POST /teams/:teamId/workflows/:id/publish` +- [ ] Ownership guard: all mutating ops verify `workflow.team_id == :teamId` +- [ ] Reuse existing `WorkflowHandler` methods — team ID injection, not new logic + +**CS1 — Frontend: Workflows Tab in Team Admin:** +- [ ] 9th tab "Workflows" in team admin modal +- [ ] List team workflows with status badge (active/inactive) +- [ ] Create/edit workflow form (name, slug, entry_mode, branding, retention) +- [ ] Stage builder (add/reorder/delete stages, persona picker, history_mode) +- [ ] Publish button with version display +- [ ] Adapted from platform admin workflow UI (`_loadAdminWorkflows` reference) + +**Future (not blocking):** +- Team admin modal → full surface package (when tab count justifies it) --- @@ -371,8 +420,8 @@ on a 3-node cluster. An IT team can operate the platform without reading Go source code. Team admins build workflows visually. **Requires all of:** -- Extension track through v0.31.0 (full package ecosystem, visual - workflow builder, SDK-based surfaces, editor package proving E2E) +- Extension track through v0.31.2 (full package ecosystem, visual + workflow builder, SDK-based surfaces, team workflow self-service) - Operations track through v0.34.0 (multi-replica HA, observability, data portability) diff --git a/packages/dashboard/css/main.css b/packages/dashboard/css/main.css new file mode 100644 index 0000000..05d5f1f --- /dev/null +++ b/packages/dashboard/css/main.css @@ -0,0 +1,197 @@ +/* ========================================== + Dashboard Surface — Layout Only + ========================================== + v0.31.1: ZERO component CSS overrides. + Only grid, flex, sizing, and spacing. + All SDK components style themselves. + ========================================== */ + +.surface-dashboard { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* ── Topbar ──────────────────────────────── */ + +.dashboard-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + height: 44px; + flex-shrink: 0; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.dashboard-topbar-back { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-2); + text-decoration: none; + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + border-radius: var(--radius); + transition: color 0.15s, background 0.15s; +} + +.dashboard-topbar-back:hover { + color: var(--text); + background: var(--bg-hover); +} + +.dashboard-topbar-title { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.dashboard-topbar-sep { + width: 1px; + height: 18px; + background: var(--border); +} + +.dashboard-topbar-spacer { + flex: 1; +} + +/* ── Body ────────────────────────────────── */ + +.dashboard-body { + display: flex; + flex: 1; + min-height: 0; +} + +/* ── Sidebar ─────────────────────────────── */ + +.dashboard-sidebar { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + min-height: 0; +} + +/* ── Main Content ────────────────────────── */ + +.dashboard-main { + flex: 1; + overflow-y: auto; + padding: 20px; + min-width: 0; +} + +.dashboard-greeting { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.dashboard-subtitle { + font-size: 13px; + color: var(--text-3); + margin-bottom: 20px; +} + +.dashboard-filter-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.dashboard-filter-label { + font-size: 12px; + font-weight: 600; + color: var(--text-2); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ── Cards Grid ──────────────────────────── */ + +.dashboard-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.dashboard-card { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + transition: border-color 0.15s; +} + +.dashboard-card:hover { + border-color: var(--border-elevated); +} + +.dashboard-card-header { + display: flex; + align-items: center; + gap: 8px; +} + +.dashboard-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-card-meta { + font-size: 11px; + color: var(--text-3); +} + +.dashboard-card-desc { + font-size: 12px; + color: var(--text-2); + line-height: 1.4; +} + +.dashboard-card-actions { + display: flex; + justify-content: flex-end; + margin-top: 4px; +} + +.dashboard-empty { + text-align: center; + color: var(--text-3); + font-size: 13px; + padding: 40px 20px; +} + +/* ── Admin Section ───────────────────────── */ + +.dashboard-admin-section { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.dashboard-section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-2); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} diff --git a/packages/dashboard/js/main.js b/packages/dashboard/js/main.js new file mode 100644 index 0000000..a30d079 --- /dev/null +++ b/packages/dashboard/js/main.js @@ -0,0 +1,414 @@ +// ========================================== +// Chat Switchboard — Dashboard Package (v0.31.1) +// ========================================== +// SDK Exercise Surface. Exercises every sw.* primitive with ZERO +// component CSS overrides. All styling comes from the SDK itself. +// +// Primitives exercised: +// sw.userMenu, sw.menu, sw.toolbar, sw.tabs, sw.dropdown, +// sw.chat, sw.notes, sw.modal, sw.confirm, sw.toast, +// sw.on/emit, sw.api, sw.user/isAdmin, sw.theme, sw.pipe +// +// Dependencies (loaded by base.html): +// API, UI, App, sw, esc (ui-primitives) +// ========================================== + +(function () { + 'use strict'; + + const SURFACE_ID = 'dashboard'; + if (window.__SURFACE__ !== SURFACE_ID) return; + + const base = window.__BASE__ || ''; + + // ── Init ───────────────────────────────────── + + async function _init() { + const mount = document.getElementById('extension-mount'); + if (!mount) return; + + const surface = document.createElement('div'); + surface.className = 'surface-dashboard'; + surface.id = 'dashboardSurface'; + mount.appendChild(surface); + + // ── Topbar ── + const topbar = _buildTopbar(); + surface.appendChild(topbar); + + // sw.userMenu — flyout: 'down' from topbar + sw.userMenu(topbar, { flyout: 'down' }); + + // ── Body ── + const body = document.createElement('div'); + body.className = 'dashboard-body'; + surface.appendChild(body); + + // ── Sidebar (tabs: activity + notes) ── + const sidebar = document.createElement('div'); + sidebar.className = 'dashboard-sidebar'; + body.appendChild(sidebar); + + // sw.tabs — two tabs + const tabs = sw.tabs(sidebar, { + tabs: [ + { id: 'activity', label: 'Activity' }, + { id: 'notes', label: 'Notes' }, + ], + activeTab: 'activity', + onActivate: function (id) { + if (id === 'notes' && !_notesLoaded) { + _notesLoaded = true; + if (_notePanel) { + _notePanel.loadNotesList(); + _notePanel.loadNoteFolders(); + } + } + }, + }); + + // sw.chat — standalone in activity tab + const activityPanel = tabs.getPanel('activity'); + if (activityPanel) { + sw.chat(activityPanel, { id: 'dash', standalone: true }); + } + + // sw.notes — lazy-loaded in notes tab + let _notePanel = null; + let _notesLoaded = false; + const notesPanel = tabs.getPanel('notes'); + if (notesPanel) { + _notePanel = sw.notes(notesPanel, { projectId: null }); + } + + // ── Main content area ── + const main = document.createElement('div'); + main.className = 'dashboard-main'; + body.appendChild(main); + + _buildMainContent(main); + + // ── Theme reactivity ── + sw.theme.on('change', function (resolved) { + const cards = main.querySelectorAll('.dashboard-card'); + cards.forEach(function (c) { + c.style.borderColor = ''; // reset to CSS default for new theme + }); + }); + + // ── Cross-component events ── + sw.on('dashboard.filter.changed', function (payload) { + _loadChannels(main.querySelector('.dashboard-cards'), payload.value); + }); + + console.log('[DashboardPkg] Mounted'); + } + + // ── Topbar ────────────────────────────────── + + function _buildTopbar() { + const el = document.createElement('div'); + el.className = 'dashboard-topbar'; + el.innerHTML = + '' + + '' + + 'Back' + + '' + + '
' + + 'Dashboard' + + '
'; + + // sw.toolbar — action buttons + const toolbarItems = [ + { + id: 'refresh', + icon: '', + title: 'Refresh', + onClick: function () { + const cards = document.querySelector('.dashboard-cards'); + if (cards) _loadChannels(cards, _currentFilter); + sw.toast('Refreshed', 'success'); + }, + }, + ]; + + // sw.isAdmin — conditionally show admin action + if (sw.isAdmin) { + toolbarItems.push({ + id: 'admin', + icon: '', + title: 'Admin Panel', + onClick: function () { window.location.href = base + '/admin'; }, + }); + } + + sw.toolbar(el, { items: toolbarItems }); + + return el; + } + + // ── Main Content ──────────────────────────── + + let _currentFilter = ''; + + function _buildMainContent(main) { + // sw.user — greeting + const user = sw.user; + const greeting = document.createElement('div'); + greeting.className = 'dashboard-greeting'; + greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : ''); + main.appendChild(greeting); + + const subtitle = document.createElement('div'); + subtitle.className = 'dashboard-subtitle'; + subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : ''); + main.appendChild(subtitle); + + // ── Filter bar ── + const filterBar = document.createElement('div'); + filterBar.className = 'dashboard-filter-bar'; + main.appendChild(filterBar); + + const filterLabel = document.createElement('span'); + filterLabel.className = 'dashboard-filter-label'; + filterLabel.textContent = 'Filter'; + filterBar.appendChild(filterLabel); + + // sw.dropdown — channel type filter + sw.dropdown(filterBar, { + items: [ + { value: '', label: 'All channels' }, + { value: 'channel', label: 'Team channels' }, + { value: 'direct', label: 'Direct chats' }, + { value: 'workflow', label: 'Workflows' }, + ], + value: '', + onChange: function (value) { + _currentFilter = value; + // sw.emit — cross-component event + sw.emit('dashboard.filter.changed', { value: value }); + }, + }); + + // ── Cards ── + const cards = document.createElement('div'); + cards.className = 'dashboard-cards'; + main.appendChild(cards); + + _loadChannels(cards, ''); + + // ── Admin section (sw.isAdmin) ── + if (sw.isAdmin) { + const adminSection = document.createElement('div'); + adminSection.className = 'dashboard-admin-section'; + main.appendChild(adminSection); + + const sectionTitle = document.createElement('div'); + sectionTitle.className = 'dashboard-section-title'; + sectionTitle.textContent = 'Administration'; + adminSection.appendChild(sectionTitle); + + const adminCards = document.createElement('div'); + adminCards.className = 'dashboard-cards'; + adminSection.appendChild(adminCards); + + _loadAdminCards(adminCards); + } + } + + // ── Channel Cards ─────────────────────────── + + async function _loadChannels(container, typeFilter) { + if (!container) return; + container.innerHTML = '
Loading\u2026
'; + + try { + // sw.api — real API call + let url = '/api/v1/channels?page=1&per_page=12'; + if (typeFilter) url += '&type=' + encodeURIComponent(typeFilter); + const resp = await sw.api.get(url); + const channels = resp.data || resp || []; + + container.innerHTML = ''; + if (!channels.length) { + container.innerHTML = '
No channels found
'; + return; + } + + channels.forEach(function (ch) { + container.appendChild(_buildChannelCard(ch)); + }); + } catch (e) { + container.innerHTML = '
Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '
'; + } + } + + function _buildChannelCard(ch) { + const card = document.createElement('div'); + card.className = 'dashboard-card'; + + const header = document.createElement('div'); + header.className = 'dashboard-card-header'; + card.appendChild(header); + + const title = document.createElement('div'); + title.className = 'dashboard-card-title'; + title.textContent = ch.title || ch.name || 'Untitled'; + header.appendChild(title); + + const meta = document.createElement('div'); + meta.className = 'dashboard-card-meta'; + meta.textContent = ch.type || ''; + card.appendChild(meta); + + if (ch.description) { + const desc = document.createElement('div'); + desc.className = 'dashboard-card-desc'; + desc.textContent = ch.description.slice(0, 120); + card.appendChild(desc); + } + + const updated = ch.updated_at || ch.created_at; + if (updated) { + const date = document.createElement('div'); + date.className = 'dashboard-card-meta'; + date.textContent = new Date(updated).toLocaleDateString(); + card.appendChild(date); + } + + // ── Card action menu ── + const actions = document.createElement('div'); + actions.className = 'dashboard-card-actions'; + card.appendChild(actions); + + const menuBtn = document.createElement('button'); + menuBtn.className = 'icon-btn'; + menuBtn.title = 'Actions'; + menuBtn.innerHTML = ''; + actions.appendChild(menuBtn); + + // sw.menu — per-card action flyout + sw.menu(menuBtn, { + items: [ + { + label: 'Open', + icon: '', + onClick: function () { + window.location.href = base + '/chat/' + ch.id; + }, + }, + { + label: 'Details', + icon: '', + onClick: function () { + // sw.modal — show channel details + _showDetailModal(ch); + }, + }, + { divider: true }, + { + label: 'Delete', + danger: true, + icon: '', + onClick: function () { + _deleteChannel(ch); + }, + }, + ], + position: 'down', + }); + + return card; + } + + // ── Detail Modal ──────────────────────────── + + function _showDetailModal(ch) { + // Build modal content + let existing = document.getElementById('dashDetailModal'); + if (existing) existing.remove(); + + const modal = document.createElement('div'); + modal.id = 'dashDetailModal'; + modal.className = 'modal-overlay'; + modal.style.cssText = 'position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--overlay)'; + + const box = document.createElement('div'); + box.style.cssText = 'background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto;'; + + box.innerHTML = + '

' + (typeof esc === 'function' ? esc(ch.title || 'Untitled') : (ch.title || 'Untitled')) + '

' + + '
Type: ' + (typeof esc === 'function' ? esc(ch.type || 'unknown') : (ch.type || 'unknown')) + '
' + + '
ID: ' + (typeof esc === 'function' ? esc(ch.id || '') : (ch.id || '')) + '
' + + (ch.description ? '
Description: ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '
' : '') + + (ch.created_at ? '
Created: ' + new Date(ch.created_at).toLocaleString() + '
' : '') + + '
' + + '' + + '' + + '
'; + + modal.appendChild(box); + document.body.appendChild(modal); + + modal.addEventListener('click', function (e) { + if (e.target === modal) modal.remove(); + }); + document.getElementById('dashDetailClose').addEventListener('click', function () { modal.remove(); }); + document.getElementById('dashDetailOpen').addEventListener('click', function () { + window.location.href = base + '/chat/' + ch.id; + }); + } + + // ── Delete Channel ────────────────────────── + + async function _deleteChannel(ch) { + // sw.confirm — destructive action guard + const ok = await sw.confirm('Delete "' + (ch.title || 'Untitled') + '"? This cannot be undone.'); + if (!ok) return; + + try { + // sw.api.del — real DELETE call + await sw.api.del('/api/v1/channels/' + ch.id); + // sw.toast — success feedback + sw.toast('Channel deleted', 'success'); + // Refresh cards + const cards = document.querySelector('.dashboard-cards'); + if (cards) _loadChannels(cards, _currentFilter); + } catch (e) { + // sw.toast — error feedback + sw.toast('Delete failed: ' + e.message, 'error'); + } + } + + // ── Admin Cards ───────────────────────────── + + async function _loadAdminCards(container) { + try { + const resp = await sw.api.get('/api/v1/admin/packages'); + const packages = resp.data || resp || []; + + container.innerHTML = ''; + packages.forEach(function (pkg) { + const card = document.createElement('div'); + card.className = 'dashboard-card'; + card.innerHTML = + '
' + + '
' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '
' + + '
' + + '
' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '
' + + '
' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '
'; + container.appendChild(card); + }); + + if (!packages.length) { + container.innerHTML = '
No packages installed
'; + } + } catch (_) { + container.innerHTML = '
Failed to load packages
'; + } + } + + // ── Boot ───────────────────────────────────── + + document.addEventListener('DOMContentLoaded', _init); +})(); diff --git a/packages/dashboard/manifest.json b/packages/dashboard/manifest.json new file mode 100644 index 0000000..bcd68e2 --- /dev/null +++ b/packages/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "dashboard", + "title": "Dashboard", + "type": "full", + "version": "0.31.1", + "tier": "browser", + "author": "Chat Switchboard", + "description": "Project dashboard exercising all SDK primitives", + "route": "/s/dashboard", + "permissions": [], + "settings": [ + { "key": "refresh_interval", "label": "Auto-Refresh (seconds)", "type": "number", "default": 0 } + ] +} diff --git a/packages/editor/js/main.js b/packages/editor/js/main.js index 5493fa5..47876dd 100644 --- a/packages/editor/js/main.js +++ b/packages/editor/js/main.js @@ -317,13 +317,15 @@ // ── Assist pane (tabbed: chat + notes) via SDK ── if (assistPaneInfo?.tabs) { - // Chat tab + // Chat tab — standalone mode handles everything (streaming, model selector, history) const chatPanel = assistPaneInfo.getTabPanel('chat'); if (chatPanel) { - const chatPane = sw.chat(chatPanel, { id: PREFIX, standalone: true }); + const chatPane = sw.chat(chatPanel, { + id: PREFIX, + standalone: true, + getContext: () => _getFileContext(codeEditor), + }); if (chatPane) { - chatPane.showWelcome(); - _initAssistChat(chatPane, codeEditor); const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat'); if (chatTab) chatTab.instance = chatPane; } @@ -409,227 +411,22 @@ } catch (_) {} } - // ── Assist Chat ───────────────────────────── + // ── File Context (editor-specific, passed to ChatPane via getContext) ── - function _initAssistChat(chatPane, codeEditor) { - const inputEl = chatPane.inputEl; - const sendBtn = chatPane.sendBtnEl; - const headerEl = document.getElementById(PREFIX + 'ChatHeader'); - const selectEl = document.getElementById(PREFIX + 'ChatSelect'); - const newBtnEl = document.getElementById(PREFIX + 'ChatNewBtn'); - const modelSelEl = document.getElementById(PREFIX + 'ModelSel'); - if (!inputEl) return; - - if (headerEl) headerEl.style.display = ''; - - let _channelId = null, _messages = [], _sending = false, _abortController = null; - let _selectedModel = App.settings?.model || ''; - - async function _initModelSelector() { - if (!modelSelEl) return; - if (!App.models?.length && typeof fetchModels === 'function') await fetchModels(); - const models = App.models || []; - if (!models.length) return; - const sel = document.createElement('select'); - sel.className = 'chat-pane-model-select'; - sel.title = 'Select model'; - models.forEach(m => { - if (m.hidden) return; - const opt = document.createElement('option'); - opt.value = m.isPersona ? (m.personaId || m.id) : m.id; - opt.textContent = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id); - if (m.id === _selectedModel || m.personaId === _selectedModel) opt.selected = true; - sel.appendChild(opt); - }); - sel.addEventListener('change', () => { _selectedModel = sel.value; }); - modelSelEl.innerHTML = ''; - modelSelEl.appendChild(sel); - } - - async function _loadChatHistory() { - if (!selectEl) return; - try { - const resp = await API.listChannels(1, 20, 'direct'); - const channels = resp.data || resp || []; - selectEl.innerHTML = ''; - channels.forEach(ch => { - const opt = document.createElement('option'); - opt.value = ch.id; - opt.textContent = ch.title || 'Untitled'; - if (ch.id === _channelId) opt.selected = true; - selectEl.appendChild(opt); - }); - } catch (_) {} - } - - if (selectEl) selectEl.addEventListener('change', () => { selectEl.value ? _switchToChannel(selectEl.value) : _newChat(); }); - if (newBtnEl) newBtnEl.addEventListener('click', _newChat); - - async function _switchToChannel(channelId) { - _channelId = channelId; - _messages = []; - chatPane.clear(); - chatPane.appendTyping(); - try { - const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100'); - _messages = resp.data || resp || []; - chatPane.removeTyping(); - _renderAllMessages(); - } catch (e) { - chatPane.removeTyping(); - _appendSystemMsg('Failed to load messages: ' + e.message); + function _getFileContext(editor) { + if (!editor) return null; + try { + const openFiles = editor.getOpenFiles(); + if (!openFiles.length) return null; + const activeTab = document.querySelector('.code-editor-tab.active'); + const path = activeTab?.dataset?.path || openFiles[0]; + const inst = CodeEditor._instances?.get(PREFIX); + if (inst?._files) { + const file = inst._files.get(path); + if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) }; } - } - - function _newChat() { - _channelId = null; - _messages = []; - chatPane.showWelcome(); - if (selectEl) selectEl.value = ''; - } - - const ta = document.createElement('textarea'); - ta.placeholder = 'Ask about your code\u2026'; - ta.rows = 1; - inputEl.appendChild(ta); - ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; }); - - async function sendMessage() { - const text = ta.value.trim(); - if (!text || _sending) return; - _sending = true; - if (sendBtn) sendBtn.disabled = true; - ta.value = ''; - ta.style.height = 'auto'; - - if (!_channelId) { - try { - const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : ''); - const resp = await API.createChannel(title, _selectedModel, '', 'direct'); - _channelId = resp.id; - _loadChatHistory(); - } catch (e) { - _appendSystemMsg('Failed to create chat: ' + e.message); - _sending = false; - if (sendBtn) sendBtn.disabled = false; - return; - } - } - - _messages.push({ role: 'user', content: text }); - _renderAllMessages(); - - try { - _abortController = new AbortController(); - const fileCtx = _getFileContext(codeEditor); - const content = fileCtx - ? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```' - : text; - const resp = await API.streamCompletion(_channelId, content, _selectedModel, _abortController.signal); - await _handleStream(resp, chatPane); - } catch (e) { - if (e.name !== 'AbortError') _appendSystemMsg('Error: ' + e.message); - } - _abortController = null; - _sending = false; - if (sendBtn) sendBtn.disabled = false; - ta.focus(); - } - - if (sendBtn) sendBtn.addEventListener('click', sendMessage); - ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); - - async function _handleStream(response, pane) { - const messagesEl = pane.messagesEl; - if (!messagesEl) return; - const div = document.createElement('div'); - div.className = 'chat-msg chat-msg--assistant chat-msg--streaming'; - const contentEl = document.createElement('div'); - contentEl.className = 'chat-msg__content'; - div.appendChild(contentEl); - messagesEl.appendChild(div); - let content = ''; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const data = line.slice(6); - if (data === '[DONE]') continue; - try { - const delta = JSON.parse(data).choices?.[0]?.delta?.content; - if (delta) { - content += delta; - if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content)); - } else { - contentEl.textContent = content; - } - pane.scrollToBottom(); - } - } catch (_) {} - } - } - } catch (e) { - if (e.name !== 'AbortError') content += '\n\n**[Stream error: ' + e.message + ']**'; - } - div.classList.remove('chat-msg--streaming'); - _messages.push({ role: 'assistant', content }); - } - - function _renderAllMessages() { - if (!chatPane.messagesEl) return; - chatPane.messagesEl.innerHTML = ''; - _messages.forEach(m => { - const div = document.createElement('div'); - div.className = 'chat-msg chat-msg--' + m.role; - const c = document.createElement('div'); - c.className = 'chat-msg__content'; - if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - c.innerHTML = DOMPurify.sanitize(marked.parse(m.content || '')); - } else { - c.textContent = m.content || ''; - } - div.appendChild(c); - chatPane.messagesEl.appendChild(div); - }); - chatPane.scrollToBottom(); - } - - function _appendSystemMsg(text) { - const div = document.createElement('div'); - div.className = 'chat-msg chat-msg--system'; - div.innerHTML = '
' + esc(text) + '
'; - chatPane.messagesEl?.appendChild(div); - chatPane.scrollToBottom(); - } - - function _getFileContext(editor) { - if (!editor) return null; - try { - const openFiles = editor.getOpenFiles(); - if (!openFiles.length) return null; - const activeTab = document.querySelector('.code-editor-tab.active'); - const path = activeTab?.dataset?.path || openFiles[0]; - const inst = CodeEditor._instances?.get(PREFIX); - if (inst?._files) { - const file = inst._files.get(path); - if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) }; - } - } catch (_) {} - return null; - } - - function _deferredInit() { _initModelSelector(); _loadChatHistory(); } - if (App.models?.length) _deferredInit(); - else document.addEventListener('sb:ready', _deferredInit, { once: true }); + } catch (_) {} + return null; } // ── State Persistence (localStorage) ───────── diff --git a/packages/icd-test-runner/js/crud/dashboard-package.js b/packages/icd-test-runner/js/crud/dashboard-package.js new file mode 100644 index 0000000..1fcc7c5 --- /dev/null +++ b/packages/icd-test-runner/js/crud/dashboard-package.js @@ -0,0 +1,122 @@ +/** + * ICD Test Runner — CRUD: Dashboard Package (v0.31.1) + * Tests the dashboard .pkg lifecycle: install → surface accessible → + * settings → export/import round-trip. + * Uses T.crud.buildTestZip() from _helpers.js. + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + if (!T.crud) T.crud = {}; + + var buildTestZip = T.crud.buildTestZip; + + T.crud.dashboardPackage = async function (testTag) { + + if (T.user.role !== 'admin') return; + + // ── dashpkg: Dashboard package install + lifecycle ── + + var dashPkgId = null; + + await T.test('crud', 'dashboardPackage', 'dashpkg: POST /admin/packages/install (dashboard .pkg)', async function () { + var manifest = JSON.stringify({ + id: 'dashboard', + title: 'Dashboard', + type: 'full', + version: '0.31.1', + tier: 'browser', + author: 'Chat Switchboard', + description: 'Project dashboard exercising all SDK primitives', + route: '/s/dashboard', + permissions: [], + settings: [ + { key: 'refresh_interval', label: 'Auto-Refresh (seconds)', type: 'number', 'default': 0 } + ] + }); + var zipBlob = buildTestZip({ + 'manifest.json': manifest, + 'js/main.js': '// dashboard package entry point\nconsole.log("[DashboardPkg] loaded");', + 'css/main.css': '.surface-dashboard { display: flex; }' + }); + + var d = await T.apiUpload('/admin/packages/install', zipBlob, 'dashboard.pkg'); + T.assert(d.id === 'dashboard', 'package id should be "dashboard", got: ' + d.id); + dashPkgId = d.id; + + T.registerCleanup(async function () { + if (dashPkgId) { + var token = await T.getAuthToken(); + return T.authFetch(token, 'DELETE', '/admin/packages/' + dashPkgId); + } + }); + }); + + if (!dashPkgId) return; + + await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages/:id (verify type full)', async function () { + var d = await T.apiGet('/admin/packages/' + dashPkgId); + T.assertShape(d, T.S.extension, 'dashboard package'); + T.assert(d.type === 'full', 'type should be "full", got: ' + d.type); + T.assert(d.tier === 'browser', 'tier should be "browser", got: ' + d.tier); + T.assert(d.source === 'extension', 'source should be "extension", got: ' + d.source); + T.assert(d.enabled === true, 'newly installed package should be enabled'); + T.assert(d.version === '0.31.1', 'version mismatch'); + }); + + await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/surfaces (dashboard in list)', async function () { + var d = await T.apiGet('/admin/surfaces'); + T.assertHasKey(d, 'data', '/admin/surfaces'); + var found = d.data.some(function (s) { return s.id === 'dashboard'; }); + T.assert(found, 'dashboard package should appear in surfaces list'); + }); + + // ── Settings ── + + await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages/:id/settings', async function () { + var d = await T.apiGet('/admin/packages/' + dashPkgId + '/settings'); + T.assert(typeof d === 'object', 'settings should be an object'); + }); + + await T.test('crud', 'dashboardPackage', 'dashpkg: PUT /admin/packages/:id/settings', async function () { + var settings = { refresh_interval: 30 }; + var d = await T.apiPut('/admin/packages/' + dashPkgId + '/settings', settings); + T.assert(d._status === 200 || d._status === 204 || !d._status, 'settings PUT should succeed'); + + // Verify persistence + var check = await T.apiGet('/admin/packages/' + dashPkgId + '/settings'); + T.assert(check.refresh_interval === 30 || check.data?.refresh_interval === 30, + 'refresh_interval should persist as 30'); + }); + + // ── Export/Import Round-Trip ── + + var exportBlob = null; + + await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages/:id/export', async function () { + var token = await T.getAuthToken(); + var resp = await fetch(T.base + '/api/v1/admin/packages/' + dashPkgId + '/export', { + headers: { 'Authorization': 'Bearer ' + token } + }); + T.assert(resp.ok, 'export should return 200, got: ' + resp.status); + exportBlob = await resp.blob(); + T.assert(exportBlob.size > 0, 'export blob should not be empty'); + }); + + if (exportBlob) { + await T.test('crud', 'dashboardPackage', 'dashpkg: DELETE + re-import round-trip', async function () { + var token = await T.getAuthToken(); + var delResp = await T.authFetch(token, 'DELETE', '/admin/packages/' + dashPkgId); + T.assert(!delResp._status || delResp._status < 300, 'delete should succeed'); + + var d = await T.apiUpload('/admin/packages/install', exportBlob, 'dashboard.pkg'); + T.assert(d.id === 'dashboard', 'reimported package id should be "dashboard"'); + + var check = await T.apiGet('/admin/packages/' + dashPkgId); + T.assert(check.id === 'dashboard', 'reimported dashboard should be readable'); + T.assert(check.type === 'full', 'reimported type should be "full"'); + }); + } + }; +})(); diff --git a/packages/icd-test-runner/js/main.js b/packages/icd-test-runner/js/main.js index 063dfee..ff9f2b6 100644 --- a/packages/icd-test-runner/js/main.js +++ b/packages/icd-test-runner/js/main.js @@ -79,6 +79,7 @@ 'crud/extensions.js', 'crud/surfaces.js', 'crud/editor-package.js', + 'crud/dashboard-package.js', // Orchestrator (must come after all crud/*.js) 'tier-crud.js', 'tier-authz.js', diff --git a/packages/icd-test-runner/js/tier-crud.js b/packages/icd-test-runner/js/tier-crud.js index 2411bac..744bb32 100644 --- a/packages/icd-test-runner/js/tier-crud.js +++ b/packages/icd-test-runner/js/tier-crud.js @@ -30,6 +30,7 @@ if (C.personas) await C.personas(testTag); if (C.extensions) await C.extensions(testTag); if (C.surfaces) await C.surfaces(testTag); - if (C.editorPackage) await C.editorPackage(testTag); + if (C.editorPackage) await C.editorPackage(testTag); + if (C.dashboardPackage) await C.dashboardPackage(testTag); }; })(); diff --git a/server/pages/templates/components/user-menu.html b/server/pages/templates/components/user-menu.html index f06261f..b5ce9f4 100644 --- a/server/pages/templates/components/user-menu.html +++ b/server/pages/templates/components/user-menu.html @@ -13,7 +13,7 @@ User -
+
'; container.appendChild(el); - // ChatPane.create expects element refs, not ID prefix const instance = this.create({ id: pfx, messagesEl: document.getElementById(pfx + 'ChatMessages'), @@ -191,10 +190,256 @@ const ChatPane = { channelId: opts.channelId || null, standalone: opts.standalone !== false, }); + + // v0.31.1: Standalone mode — self-contained chat with streaming + if (opts.standalone) { + _initStandalone(instance, pfx, opts); + } + return instance; }, }; +// ── Standalone Chat Logic ───────────────────── +// Absorbed from editor package v0.31.0. Provides full chat functionality +// (textarea, model selector, history, streaming, channel management) +// so that sw.chat(el, {standalone: true}) works with zero external wiring. + +function _initStandalone(pane, pfx, opts) { + const headerEl = document.getElementById(pfx + 'ChatHeader'); + const selectEl = document.getElementById(pfx + 'ChatSelect'); + const newBtnEl = document.getElementById(pfx + 'ChatNewBtn'); + const modelSelEl = document.getElementById(pfx + 'ModelSel'); + const inputEl = pane.inputEl; + const sendBtn = pane.sendBtnEl; + if (!inputEl) return; + + // Show header (chat-select + model-selector + new-chat button) + if (headerEl) headerEl.style.display = ''; + + // Show welcome + pane.showWelcome(); + + let _channelId = null, _messages = [], _sending = false, _abortController = null; + let _selectedModel = (typeof App !== 'undefined' && App.settings?.model) || ''; + const _getContext = opts.getContext || null; + + // ── Model Selector ────────────────────── + + async function _initModelSelector() { + if (!modelSelEl) return; + if (typeof App === 'undefined') return; + if (!App.models?.length && typeof fetchModels === 'function') await fetchModels(); + const models = App.models || []; + if (!models.length) return; + const sel = document.createElement('select'); + sel.className = 'chat-pane-model-select'; + sel.title = 'Select model'; + models.forEach(function (m) { + if (m.hidden) return; + const opt = document.createElement('option'); + opt.value = m.isPersona ? (m.personaId || m.id) : m.id; + opt.textContent = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id); + if (m.id === _selectedModel || m.personaId === _selectedModel) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener('change', function () { _selectedModel = sel.value; }); + modelSelEl.innerHTML = ''; + modelSelEl.appendChild(sel); + } + + // ── Chat History ──────────────────────── + + async function _loadChatHistory() { + if (!selectEl || typeof API === 'undefined') return; + try { + const resp = await API.listChannels(1, 20, 'direct'); + const channels = resp.data || resp || []; + selectEl.innerHTML = ''; + channels.forEach(function (ch) { + const opt = document.createElement('option'); + opt.value = ch.id; + opt.textContent = ch.title || 'Untitled'; + if (ch.id === _channelId) opt.selected = true; + selectEl.appendChild(opt); + }); + } catch (_) {} + } + + if (selectEl) selectEl.addEventListener('change', function () { + selectEl.value ? _switchToChannel(selectEl.value) : _newChat(); + }); + if (newBtnEl) newBtnEl.addEventListener('click', _newChat); + + async function _switchToChannel(channelId) { + _channelId = channelId; + _messages = []; + pane.clear(); + pane.appendTyping(); + try { + const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100'); + _messages = resp.data || resp || []; + pane.removeTyping(); + _renderAllMessages(); + } catch (e) { + pane.removeTyping(); + _appendSystemMsg('Failed to load messages: ' + e.message); + } + } + + function _newChat() { + _channelId = null; + _messages = []; + pane.showWelcome(); + if (selectEl) selectEl.value = ''; + } + + // ── Textarea ──────────────────────────── + + const ta = document.createElement('textarea'); + ta.placeholder = 'Type a message\u2026'; + ta.rows = 1; + inputEl.appendChild(ta); + ta.addEventListener('input', function () { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; + }); + + // ── Send + Streaming ──────────────────── + + async function _sendMessage() { + const text = ta.value.trim(); + if (!text || _sending) return; + _sending = true; + if (sendBtn) sendBtn.disabled = true; + ta.value = ''; + ta.style.height = 'auto'; + + if (!_channelId) { + try { + const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : ''); + const resp = await API.createChannel(title, _selectedModel, '', 'direct'); + _channelId = resp.id; + _loadChatHistory(); + } catch (e) { + _appendSystemMsg('Failed to create chat: ' + e.message); + _sending = false; + if (sendBtn) sendBtn.disabled = false; + return; + } + } + + _messages.push({ role: 'user', content: text }); + _renderAllMessages(); + + try { + _abortController = new AbortController(); + let content = text; + if (_getContext) { + const ctx = _getContext(); + if (ctx) content = text + '\n\n[Context: Currently editing ' + ctx.path + ']\n```\n' + ctx.content + '\n```'; + } + const resp = await API.streamCompletion(_channelId, content, _selectedModel, _abortController.signal); + await _handleStream(resp); + } catch (e) { + if (e.name !== 'AbortError') _appendSystemMsg('Error: ' + e.message); + } + _abortController = null; + _sending = false; + if (sendBtn) sendBtn.disabled = false; + ta.focus(); + } + + if (sendBtn) sendBtn.addEventListener('click', _sendMessage); + ta.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); _sendMessage(); } + }); + + // ── Stream Handler ────────────────────── + + async function _handleStream(response) { + const messagesEl = pane.messagesEl; + if (!messagesEl) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg--assistant chat-msg--streaming'; + const contentEl = document.createElement('div'); + contentEl.className = 'chat-msg__content'; + div.appendChild(contentEl); + messagesEl.appendChild(div); + let content = ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6); + if (data === '[DONE]') continue; + try { + const delta = JSON.parse(data).choices?.[0]?.delta?.content; + if (delta) { + content += delta; + if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { + contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content)); + } else { + contentEl.textContent = content; + } + pane.scrollToBottom(); + } + } catch (_) {} + } + } + } catch (e) { + if (e.name !== 'AbortError') content += '\n\n**[Stream error: ' + e.message + ']**'; + } + div.classList.remove('chat-msg--streaming'); + _messages.push({ role: 'assistant', content: content }); + } + + // ── Message Rendering ─────────────────── + + function _renderAllMessages() { + if (!pane.messagesEl) return; + pane.messagesEl.innerHTML = ''; + _messages.forEach(function (m) { + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg--' + m.role; + const c = document.createElement('div'); + c.className = 'chat-msg__content'; + if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { + c.innerHTML = DOMPurify.sanitize(marked.parse(m.content || '')); + } else { + c.textContent = m.content || ''; + } + div.appendChild(c); + pane.messagesEl.appendChild(div); + }); + pane.scrollToBottom(); + } + + function _appendSystemMsg(text) { + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg--system'; + div.innerHTML = '
' + + (typeof esc === 'function' ? esc(text) : text) + '
'; + pane.messagesEl?.appendChild(div); + pane.scrollToBottom(); + } + + // ── Deferred Init ─────────────────────── + + function _deferredInit() { _initModelSelector(); _loadChatHistory(); } + if (typeof App !== 'undefined' && App.models?.length) _deferredInit(); + else document.addEventListener('sb:ready', _deferredInit, { once: true }); +} + + // ── Exports ───────────────────────────────── sb.ns('ChatPane', ChatPane); diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js index 89c42fd..80b7215 100644 --- a/src/js/switchboard-sdk.js +++ b/src/js/switchboard-sdk.js @@ -37,6 +37,41 @@ const Switchboard = { const sw = Object.create(null); + // ── Flyout Positioning Helper ────────────────────── + // Escapes overflow:hidden/auto ancestors by switching to position:fixed. + + function _hasClippingAncestor(el) { + let node = el.parentElement; + while (node && node !== document.body) { + const ov = getComputedStyle(node).overflow; + if (ov === 'hidden' || ov === 'auto' || ov === 'scroll') return true; + node = node.parentElement; + } + return false; + } + + function _positionFlyout(flyoutEl, anchorEl, position) { + if (!_hasClippingAncestor(flyoutEl)) { + flyoutEl.removeAttribute('data-fixed'); + flyoutEl.style.top = ''; + flyoutEl.style.right = ''; + flyoutEl.style.bottom = ''; + flyoutEl.style.left = ''; + return; + } + flyoutEl.setAttribute('data-fixed', ''); + const rect = anchorEl.getBoundingClientRect(); + flyoutEl.style.left = ''; + if (position === 'up') { + flyoutEl.style.bottom = (window.innerHeight - rect.top + 4) + 'px'; + flyoutEl.style.top = ''; + } else { + flyoutEl.style.top = (rect.bottom + 4) + 'px'; + flyoutEl.style.bottom = ''; + } + flyoutEl.style.right = Math.max(0, window.innerWidth - rect.right) + 'px'; + } + // ── Identity ───────────────────────────────────────── Object.defineProperty(sw, 'user', { @@ -265,7 +300,7 @@ const Switchboard = { const result = { el: wrap, flyoutEl: flyout, - open() { flyout.classList.add('open'); _open = true; }, + open() { _positionFlyout(flyout, anchor, pos); flyout.classList.add('open'); _open = true; }, close() { flyout.classList.remove('open'); _open = false; }, toggle() { _open ? result.close() : result.open(); }, isOpen() { return _open; }, @@ -762,6 +797,7 @@ const Switchboard = { const _opts = Object.assign({ user: window.__USER__ || null, handlers: _defaultMenuHandlers, + _positionFn: _positionFlyout, }, opts || {}); const instance = UserMenu.mount(container, _opts); if (!UserMenu.primary) UserMenu.primary = instance; diff --git a/src/js/user-menu.js b/src/js/user-menu.js index c42d213..631e815 100644 --- a/src/js/user-menu.js +++ b/src/js/user-menu.js @@ -156,7 +156,7 @@ UserMenu.mount = function (container, opts) { '
' + '' + '' + - '
' + + '
' + '