diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bbac6..97b2c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ headers start with the first tagged release. ## Unreleased +### Changed +- **UX rework, phase 1 — hierarchy & trust in the document view.** The + single document surface got real hierarchy and a coat of paint, all + client-side (no new routes or server APIs). Design tokens (CSS custom + properties for ink/paper/accent/human/danger) now back every color. + - **Header** replaces the row of seven identical pills with three zones: + a breadcrumb ` / ` (title derived live from the doc's + first heading, raw path in the tooltip) with a demoted connection status + *dot*; an overlapping presence avatar stack (round for humans, squared in + the accent color for agents, name in the tooltip); an `Edit | Both | Read` + segmented mode toggle; and a `⋯` menu holding comment / history / versions + / rename / move / delete (delete is red, behind the menu). + - **View mode** — the URL hash now carries `mode=edit|both|read` (replacing + the boolean `preview=1`; no back-compat). Read mode renders the preview + full-width with the editor hidden. + - **Prose ergonomics** — editor and preview cap at a 72ch centered column, + render prose in a proportional font (fenced code stays monospaced), + dim markdown syntax marks and enlarge headings via a CodeMirror + `HighlightStyle`, and drop the line-number gutter. + - **In-app dialogs & toasts** — a new `dialogs.ts` (`askText`, `askChoice`, + `askConfirm`, `toast`) replaces every native `prompt`/`confirm`/`alert` + in the client. Move-doc is now a project picker; errors surface as toasts, + successes as brief confirmations. + - **Project bar** — labeled `+ new` button plus a `⋯` project menu + (rename, connect an agent, delete); the MCP dialog's clipped command line + now wraps. + - **Empty states & login** — centered CTAs for an empty project (create a + document / connect an agent) and an empty vault (create your first + project); the login modal gained the wordmark and product context and + renders over the app background instead of a ghosted editor. + - **Trust fixes** — the project list refetches on window focus and after + mutations; the versions dialog toasts on restore and disables save while + a save is in flight. + ### Added - **Per-project MCP config** — `GET /api/projects/:p/mcp-config?username=` returns everything needed to wire an agent into a project (binary install diff --git a/src/client/dialogs.ts b/src/client/dialogs.ts new file mode 100644 index 0000000..1b822bd --- /dev/null +++ b/src/client/dialogs.ts @@ -0,0 +1,152 @@ +/** + * In-app dialogs and toasts — the app's replacement for native prompt() / + * confirm() / alert(). One reusable overlay (#dialog, same pattern as the + * #versions overlay) serves text prompts, confirmations and pick-one choices; + * toasts stack bottom-center and auto-dismiss. Enter confirms, Escape and the + * backdrop cancel, and the input is focused on open. + */ + +const overlay = document.querySelector('#dialog')! as HTMLElement; +const panel = document.querySelector('#dialog-panel')! as HTMLFormElement; +const titleEl = document.querySelector('#dialog-title')!; +const bodyEl = document.querySelector('#dialog-body')! as HTMLElement; +const input = document.querySelector('#dialog-input')! as HTMLInputElement; +const choicesEl = document.querySelector('#dialog-choices')! as HTMLElement; +const cancelButton = document.querySelector('#dialog-cancel')! as HTMLButtonElement; +const confirmButton = document.querySelector('#dialog-confirm')! as HTMLButtonElement; +const tray = document.querySelector('#toast-tray')! as HTMLElement; + +/** Resolve the open dialog and tear its wiring down; only one runs at a time. */ +let settle: ((value: unknown) => void) | null = null; + +function close(result: unknown): void { + const resolve = settle; + settle = null; + overlay.hidden = true; + bodyEl.hidden = true; + input.hidden = true; + choicesEl.hidden = true; + choicesEl.innerHTML = ''; + confirmButton.hidden = false; + confirmButton.classList.remove('danger'); + resolve?.(result); +} + +// Cancel paths shared by every dialog kind. +cancelButton.addEventListener('click', () => close(null)); +overlay.addEventListener('mousedown', (event) => { + if (event.target === overlay) { + close(null); + } +}); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !overlay.hidden) { + event.preventDefault(); + close(null); + } +}); + +interface AskTextOptions { + title: string; + hint?: string; + initial?: string; + confirmLabel?: string; +} + +/** Text prompt. Resolves the trimmed value, or null on cancel / empty input. */ +export function askText(options: AskTextOptions): Promise<string | null> { + return new Promise((resolve) => { + settle = resolve as (value: unknown) => void; + titleEl.textContent = options.title; + bodyEl.textContent = options.hint ?? ''; + bodyEl.hidden = !options.hint; + input.hidden = false; + input.value = options.initial ?? ''; + confirmButton.textContent = options.confirmLabel ?? 'OK'; + overlay.hidden = false; + input.focus(); + input.select(); + + panel.onsubmit = (event) => { + event.preventDefault(); + const value = input.value.trim(); + close(value || null); + }; + }); +} + +interface ChoiceOption { + value: string; + label: string; +} + +interface AskChoiceOptions { + title: string; + hint?: string; + options: ChoiceOption[]; +} + +/** Pick one of a fixed set of options. Resolves the chosen value, or null. */ +export function askChoice(options: AskChoiceOptions): Promise<string | null> { + return new Promise((resolve) => { + settle = resolve as (value: unknown) => void; + titleEl.textContent = options.title; + bodyEl.textContent = options.hint ?? ''; + bodyEl.hidden = !options.hint; + choicesEl.hidden = false; + confirmButton.hidden = true; + for (const option of options.options) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'dialog-choice'; + button.dataset.value = option.value; + button.textContent = option.label; + button.addEventListener('click', () => close(option.value)); + choicesEl.appendChild(button); + } + overlay.hidden = false; + (choicesEl.querySelector('button') as HTMLButtonElement | null)?.focus(); + + panel.onsubmit = (event) => event.preventDefault(); + }); +} + +interface AskConfirmOptions { + title: string; + body?: string; + confirmLabel?: string; + danger?: boolean; +} + +/** Yes/no confirmation. Resolves true only if the confirm button is chosen. */ +export function askConfirm(options: AskConfirmOptions): Promise<boolean> { + return new Promise((resolve) => { + settle = resolve as (value: unknown) => void; + titleEl.textContent = options.title; + bodyEl.textContent = options.body ?? ''; + bodyEl.hidden = !options.body; + confirmButton.textContent = options.confirmLabel ?? 'Confirm'; + confirmButton.classList.toggle('danger', options.danger === true); + overlay.hidden = false; + confirmButton.focus(); + + panel.onsubmit = (event) => { + event.preventDefault(); + close(true); + }; + }); +} + +export function toast(message: string, options: { tone?: 'ok' | 'error' } = {}): void { + const el = document.createElement('div'); + el.className = `toast toast-${options.tone ?? 'ok'}`; + el.textContent = message; + tray.appendChild(el); + // Force a reflow so the entrance transition runs from the initial state. + void el.offsetWidth; + el.classList.add('show'); + setTimeout(() => { + el.classList.remove('show'); + setTimeout(() => el.remove(), 300); + }, 3000); +} diff --git a/src/client/index.html b/src/client/index.html index 6c23897..a925db2 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -7,15 +7,21 @@ <link rel="stylesheet" href="/styles.css" /> </head> <body> - <div id="app"> + <div id="app" hidden> <aside id="sidebar"> <h1>mdio</h1> <div id="project-bar"> <select id="project-select" title="Switch project"></select> - <button id="project-new" type="button" title="New project">+</button> - <button id="project-rename" type="button" title="Rename this project">✎</button> - <button id="project-delete" type="button" title="Delete this project and all its documents">🗑</button> - <button id="project-mcp" type="button" title="MCP config to wire an agent into this project">🤖</button> + <button id="project-new" type="button" title="Create a project">+ new</button> + <div id="project-menu" class="menu"> + <button id="project-menu-toggle" type="button" class="menu-toggle" title="Project actions" aria-haspopup="true" aria-expanded="false">⋯</button> + <ul id="project-menu-list" class="menu-list" hidden> + <li><button id="project-rename" type="button">Rename project</button></li> + <li><button id="project-mcp" type="button">Connect an agent…</button></li> + <li class="menu-divider"></li> + <li><button id="project-delete" type="button" class="danger">Delete project</button></li> + </ul> + </div> </div> <input id="doc-search" type="search" placeholder="Search this project…" autocomplete="off" /> <ul id="doc-list"></ul> @@ -27,17 +33,34 @@ <h1>mdio</h1> </div> </aside> <main id="main"> - <header id="header"> - <span id="doc-title">Select a document</span> - <span id="status" data-status="disconnected">disconnected</span> + <header id="header" hidden> + <div id="doc-identity"> + <span id="status-dot" data-status="disconnected" title="disconnected"></span> + <nav id="breadcrumb" title=""> + <span id="crumb-project"></span> + <span class="crumb-sep">/</span> + <span id="crumb-title"></span> + </nav> + </div> <div id="presence"></div> - <button id="comment-add" type="button" title="Comment on the selected text">+ comment</button> - <button id="preview-toggle" type="button" title="Toggle rendered markdown preview">preview</button> - <button id="history-open" type="button" title="Replay this document's edit history">history</button> - <button id="versions-open" type="button" title="Save and restore named versions of this document">versions</button> - <button id="doc-rename" type="button" title="Rename this document">rename</button> - <button id="doc-move" type="button" title="Move this document to another project">move</button> - <button id="doc-delete" type="button" title="Delete this document">delete</button> + <div id="mode-toggle" role="group" aria-label="View mode"> + <button id="mode-edit" type="button" class="active" title="Editor only">Edit</button> + <button id="mode-both" type="button" title="Editor and preview side by side">Both</button> + <button id="mode-read" type="button" title="Rendered preview only">Read</button> + </div> + <div id="doc-menu" class="menu"> + <button id="doc-menu-toggle" type="button" class="menu-toggle" title="Document actions" aria-haspopup="true" aria-expanded="false">⋯</button> + <ul id="doc-menu-list" class="menu-list" hidden> + <li><button id="comment-add" type="button" title="Comment on the selected text">+ comment</button></li> + <li><button id="history-open" type="button">History</button></li> + <li><button id="versions-open" type="button">Versions</button></li> + <li class="menu-divider"></li> + <li><button id="doc-rename" type="button">Rename</button></li> + <li><button id="doc-move" type="button">Move…</button></li> + <li class="menu-divider"></li> + <li><button id="doc-delete" type="button" class="danger">Delete</button></li> + </ul> + </div> </header> <div id="activity" hidden></div> <div id="workspace"> @@ -57,6 +80,7 @@ <h1>mdio</h1> <div id="suggestions-list"></div> </aside> </div> + <div id="empty-state" hidden></div> </main> </div> <div id="history" hidden> @@ -79,7 +103,7 @@ <h1>mdio</h1> </header> <form id="versions-form"> <input id="versions-label" type="text" placeholder="Name this version…" maxlength="100" /> - <button type="submit">Save current version</button> + <button type="submit" id="versions-save">Save current version</button> </form> <div id="versions-list"></div> </div> @@ -93,10 +117,26 @@ <h1>mdio</h1> <div id="mcp-config-body"></div> </div> </div> + <div id="dialog" hidden> + <form id="dialog-panel"> + <h2 id="dialog-title"></h2> + <p id="dialog-body" hidden></p> + <input id="dialog-input" autocomplete="off" spellcheck="false" hidden /> + <div id="dialog-choices" hidden></div> + <div id="dialog-actions"> + <button id="dialog-cancel" type="button">Cancel</button> + <button id="dialog-confirm" type="submit">OK</button> + </div> + </form> + </div> + <div id="toast-tray"></div> <div id="login" hidden> <form id="login-form"> - <h2>Who are you?</h2> - <p class="login-hint">This name is shown to everyone editing with you.</p> + <div class="login-brand">mdio</div> + <p class="login-tagline"> + Live markdown for humans and AI agents.<br /> + Your name is what collaborators see. + </p> <input id="login-name" maxlength="40" diff --git a/src/client/main.ts b/src/client/main.ts index 434e2eb..fb1beb9 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,16 +1,19 @@ import { EditorView, basicSetup } from 'codemirror'; import { markdown } from '@codemirror/lang-markdown'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { yCollab } from 'y-codemirror.next'; import { remoteEditExtension, wireRemoteEdits } from './remote-edits'; import { commentHighlightExtension, focusThread, setShowResolved, wireComments } from './comments'; -import { setPreviewEnabled, wirePreview } from './preview'; +import { setMode, wirePreview } from './preview'; import { closeHistory, openHistory } from './history'; import { closeVersions, openVersions } from './versions'; import { openMcpConfig } from './mcp-config'; import { suggestionHighlightExtension, wireSuggestions } from './suggestions'; import { onUrlChange, readUrlState, writeUrlState, type UrlState } from './url-state'; +import { askChoice, askConfirm, askText, toast } from './dialogs'; import * as api from './api'; import { TEXT_KEY, registerAuthor } from '../shared/blame'; @@ -93,13 +96,22 @@ function promptForUser(): Promise<string> { } let user: { name: string; color: string; colorLight: string }; +const appEl = document.querySelector('#app')! as HTMLElement; const projectSelect = document.querySelector('#project-select')! as HTMLSelectElement; -const docList = document.querySelector('#doc-list')!; +const docList = document.querySelector('#doc-list')! as HTMLElement; const editorHost = document.querySelector('#editor')!; -const docTitle = document.querySelector('#doc-title')!; -const statusEl = document.querySelector('#status')! as HTMLElement; +const headerEl = document.querySelector('#header')! as HTMLElement; +const workspaceEl = document.querySelector('#workspace')! as HTMLElement; +const emptyStateEl = document.querySelector('#empty-state')! as HTMLElement; +const breadcrumbEl = document.querySelector('#breadcrumb')! as HTMLElement; +const crumbProjectEl = document.querySelector('#crumb-project')!; +const crumbTitleEl = document.querySelector('#crumb-title')!; +const statusDot = document.querySelector('#status-dot')! as HTMLElement; const presenceEl = document.querySelector('#presence')!; const activityEl = document.querySelector('#activity')! as HTMLElement; +const docSearch = document.querySelector('#doc-search')! as HTMLInputElement; +const docNewButton = document.querySelector('#doc-new')! as HTMLButtonElement; +const projectMenuEl = document.querySelector('#project-menu')! as HTMLElement; let current: { provider: WebsocketProvider; @@ -109,6 +121,25 @@ let current: { } | null = null; let currentPath: string | null = null; +/** Dim markdown syntax so prose reads first; keep code monospaced. */ +const markdownProse = HighlightStyle.define([ + { tag: tags.processingInstruction, color: 'var(--ink-faint)' }, + { tag: tags.heading1, fontSize: '1.5em', fontWeight: '700' }, + { tag: tags.heading2, fontSize: '1.3em', fontWeight: '700' }, + { tag: tags.heading3, fontSize: '1.15em', fontWeight: '700' }, + { tag: [tags.heading4, tags.heading5, tags.heading6], fontWeight: '700' }, + { tag: tags.strong, fontWeight: '700' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: [tags.link, tags.url], color: 'var(--accent)' }, + { + tag: tags.monospace, + fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace', + background: 'var(--accent-soft)', + borderRadius: '3px', + }, + { tag: tags.quote, color: 'var(--ink-soft)' }, +]); + document.querySelector('#history-open')!.addEventListener('click', () => { if (currentPath) { void openHistory(currentPath); @@ -121,7 +152,6 @@ document.querySelector('#versions-open')!.addEventListener('click', () => { } }); -const docSearch = document.querySelector('#doc-search')! as HTMLInputElement; const searchResults = document.querySelector('#search-results')!; let searchTimer: ReturnType<typeof setTimeout> | null = null; @@ -193,6 +223,12 @@ docSearch.addEventListener('keydown', (event) => { } }); +/** First letter of the peer's own name (agents drop the owner/ prefix). */ +function peerInitial(name: string): string { + const own = name.split('/').pop() ?? name; + return (own[0] ?? '?').toUpperCase(); +} + function renderPresence(provider: WebsocketProvider) { presenceEl.innerHTML = ''; for (const state of provider.awareness.getStates().values()) { @@ -200,11 +236,12 @@ function renderPresence(provider: WebsocketProvider) { if (!peer?.name) { continue; } - const chip = document.createElement('span'); - chip.className = 'peer'; - chip.style.background = peer.color ?? '#888'; - chip.textContent = peer.role === 'agent' ? `🤖 ${peer.name}` : peer.name; - presenceEl.appendChild(chip); + const avatar = document.createElement('span'); + avatar.className = peer.role === 'agent' ? 'avatar agent' : 'avatar human'; + avatar.style.background = peer.color ?? '#888'; + avatar.textContent = peerInitial(peer.name); + avatar.title = peer.name; + presenceEl.appendChild(avatar); } } @@ -224,6 +261,18 @@ function renderActivity(provider: WebsocketProvider) { activityEl.hidden = writers.length === 0; } +/** Title shown in the breadcrumb: the document's first heading, else its filename. */ +function docTitle(path: string, text: string): string { + const heading = /^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(text); + return heading ? heading[1]!.trim() : path.slice(path.lastIndexOf('/') + 1); +} + +function renderBreadcrumb(path: string, text: string): void { + crumbProjectEl.textContent = path.split('/')[0]!; + crumbTitleEl.textContent = docTitle(path, text); + breadcrumbEl.title = path; // raw path lives in the tooltip now +} + /** * `urlMode` — how this navigation reaches the URL: 'push' (user action, new * history entry, clears any focused comment), 'replace' (boot: normalize the @@ -243,9 +292,48 @@ function teardownEditor() { } } +/** Centered empty state in the main pane — no project yet, or an empty project. */ +function showEmptyState(): void { + headerEl.hidden = true; + workspaceEl.hidden = true; + emptyStateEl.hidden = false; + + const box = document.createElement('div'); + box.className = 'empty-box'; + const heading = document.createElement('h2'); + const sub = document.createElement('p'); + const actions = document.createElement('div'); + actions.className = 'empty-actions'; + + if (currentProject) { + heading.textContent = currentProject; + sub.textContent = 'No documents yet'; + actions.append( + emptyButton('+ Create a document', 'primary', () => void newDocFlow()), + emptyButton('Connect an agent', 'ghost', () => openMcpConfig(currentProject!, `${user.name}/claude`)), + ); + } else { + heading.textContent = 'Welcome to mdio'; + sub.textContent = 'Live markdown for humans and AI agents. Create a project to begin.'; + actions.append(emptyButton('+ Create your first project', 'primary', () => void newProjectFlow())); + } + + box.append(heading, sub, actions); + emptyStateEl.replaceChildren(box); +} + +function emptyButton(label: string, kind: 'primary' | 'ghost', onClick: () => void): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `empty-btn ${kind}`; + button.textContent = label; + button.addEventListener('click', onClick); + return button; +} + function closeDocument() { teardownEditor(); - docTitle.textContent = 'Select a document'; + showEmptyState(); for (const item of docList.querySelectorAll('li')) { item.classList.remove('active'); } @@ -259,7 +347,10 @@ function openDocument(path: string, urlMode: 'push' | 'replace' | 'none' = 'push } teardownEditor(); currentPath = path; - docTitle.textContent = path; + headerEl.hidden = false; + workspaceEl.hidden = false; + emptyStateEl.hidden = true; + emptyStateEl.replaceChildren(); for (const item of docList.querySelectorAll('li')) { item.classList.toggle('active', item.dataset.path === path); } @@ -270,6 +361,16 @@ function openDocument(path: string, urlMode: 'push' | 'replace' | 'none' = 'push const ytext = doc.getText(TEXT_KEY); const undoManager = new Y.UndoManager(ytext); + renderBreadcrumb(path, ytext.toString()); + let titleTimer: ReturnType<typeof setTimeout> | null = null; + const refreshTitle = () => { + if (titleTimer) { + clearTimeout(titleTimer); + } + titleTimer = setTimeout(() => renderBreadcrumb(path, ytext.toString()), 300); + }; + ytext.observe(refreshTitle); + provider.awareness.setLocalStateField('user', user); registerAuthor(doc, { name: user.name, color: user.color, role: 'human' }); const renderAwareness = () => { @@ -278,8 +379,8 @@ function openDocument(path: string, urlMode: 'push' | 'replace' | 'none' = 'push }; provider.awareness.on('change', renderAwareness); provider.on('status', ({ status }: { status: string }) => { - statusEl.textContent = status; - statusEl.dataset.status = status; + statusDot.dataset.status = status; + statusDot.title = status; }); const view = new EditorView({ @@ -287,6 +388,7 @@ function openDocument(path: string, urlMode: 'push' | 'replace' | 'none' = 'push extensions: [ basicSetup, markdown(), + syntaxHighlighting(markdownProse), EditorView.lineWrapping, yCollab(ytext, provider.awareness, { undoManager }), remoteEditExtension(), @@ -300,8 +402,12 @@ function openDocument(path: string, urlMode: 'push' | 'replace' | 'none' = 'push writeUrlState({ comment: state.comment, resolved: state.resolved }), ); const cleanupSuggestions = wireSuggestions(view, doc, user); - const cleanupPreview = wirePreview(ytext, (enabled) => writeUrlState({ preview: enabled })); + const cleanupPreview = wirePreview(ytext, (mode) => writeUrlState({ mode })); const cleanup = () => { + ytext.unobserve(refreshTitle); + if (titleTimer) { + clearTimeout(titleTimer); + } cleanupRemoteEdits(); cleanupComments(); cleanupSuggestions(); @@ -327,7 +433,7 @@ function renderMe() { } function applyViewState(state: UrlState) { - setPreviewEnabled(state.preview); + setMode(state.mode); setShowResolved(state.resolved); focusThread(state.comment); } @@ -336,6 +442,14 @@ let projects: string[] = []; let currentProject: string | null = null; let docs: string[] = []; +/** Sidebar chrome that only makes sense inside a project (search, new-doc, ⋯). */ +function renderSidebar(): void { + const inProject = currentProject !== null; + docSearch.hidden = !inProject; + docNewButton.hidden = !inProject; + projectMenuEl.hidden = !inProject; +} + function renderProjectSelect() { projectSelect.innerHTML = ''; for (const name of projects) { @@ -348,6 +462,7 @@ function renderProjectSelect() { if (currentProject) { projectSelect.value = currentProject; } + renderSidebar(); } function renderDocList() { @@ -401,153 +516,233 @@ async function enterProject(project: string | null, doc?: string) { } } -/** Run a CRUD action; API failures (conflicts, reserved names, …) surface as alerts. */ +/** Run a CRUD action; API failures (conflicts, reserved names, …) surface as toasts. */ async function attempt(action: () => Promise<void>): Promise<void> { try { await action(); } catch (error) { - alert(error instanceof Error ? error.message : String(error)); + toast(error instanceof Error ? error.message : String(error), { tone: 'error' }); } } -function wireCrud() { - projectSelect.addEventListener('change', async () => { - await loadProject(projectSelect.value); - if (docs[0]) { - openDocument(docs[0]); - } else { - closeDocument(); - writeUrlState({ doc: null, project: currentProject, comment: null }, { push: true }); - } +/** Refetch the project list (and current docs) — for focus and external changes. */ +async function refreshProjects(): Promise<void> { + let latest: string[]; + try { + latest = await api.listProjects(); + } catch { + return; + } + projects = latest; + if (currentProject && projects.includes(currentProject)) { + docs = (await api.listDocs(currentProject)).map((rel) => `${currentProject}/${rel}`); + } + renderProjectSelect(); + renderDocList(); +} + +// ── CRUD flows (shared by the menus, sidebar buttons, and empty states) ────── + +async function newProjectFlow(): Promise<void> { + const name = (await askText({ title: 'New project', hint: 'Name for the new project', confirmLabel: 'Create' }))?.trim(); + if (!name) { + return; + } + await attempt(async () => { + await api.createProject(name); + projects = await api.listProjects(); + await enterProject(name); + toast(`Created ${name}`); }); +} - document.querySelector('#project-mcp')!.addEventListener('click', () => { - if (!currentProject) { - return; - } - // Humans can't contain "/", so <me>/claude is a valid owner/agent suggestion. - void openMcpConfig(currentProject, `${user.name}/claude`); +async function renameProjectFlow(): Promise<void> { + if (!currentProject) { + return; + } + const from = currentProject; + const to = (await askText({ title: 'Rename project', initial: from, confirmLabel: 'Rename' }))?.trim(); + if (!to || to === from) { + return; + } + await attempt(async () => { + const rest = currentPath?.slice(from.length); + await api.renameProject(from, to); + projects = await api.listProjects(); + await enterProject(to, rest ? `${to}${rest}` : undefined); + toast(`Renamed to ${to}`); }); +} - document.querySelector('#project-new')!.addEventListener('click', () => { - const name = prompt('New project name')?.trim(); - if (!name) { - return; - } - void attempt(async () => { - await api.createProject(name); - projects = await api.listProjects(); - await enterProject(name); - }); +async function deleteProjectFlow(): Promise<void> { + if (!currentProject) { + return; + } + const name = currentProject; + const ok = await askConfirm({ + title: `Delete project “${name}”?`, + body: 'This deletes the project and all of its documents. This cannot be undone.', + confirmLabel: 'Delete project', + danger: true, }); + if (!ok) { + return; + } + await attempt(async () => { + await api.deleteProject(name); + projects = await api.listProjects(); + await enterProject(projects[0] ?? null); + toast(`Deleted ${name}`); + }); +} - document.querySelector('#project-rename')!.addEventListener('click', () => { - if (!currentProject) { - return; - } - const from = currentProject; - const to = prompt('Rename project', from)?.trim(); - if (!to || to === from) { - return; - } - void attempt(async () => { - const rest = currentPath?.slice(from.length); - await api.renameProject(from, to); - projects = await api.listProjects(); - await enterProject(to, rest ? `${to}${rest}` : undefined); - }); +async function newDocFlow(): Promise<void> { + if (!currentProject) { + toast('Create a project first.', { tone: 'error' }); + return; + } + const project = currentProject; + const entered = ( + await askText({ title: 'New document', hint: 'e.g. notes.md or specs/plan.md', confirmLabel: 'Create' }) + )?.trim(); + if (!entered) { + return; + } + const path = /\.(md|markdown|txt)$/i.test(entered) ? entered : `${entered}.md`; + await attempt(async () => { + await api.createDoc(project, path); + await loadProject(project); + openDocument(`${project}/${path}`); + toast(`Created ${path}`); }); +} - document.querySelector('#project-delete')!.addEventListener('click', () => { - if (!currentProject) { - return; - } - const name = currentProject; - if (!confirm(`Delete project "${name}" and all its documents?`)) { - return; - } - void attempt(async () => { - await api.deleteProject(name); - projects = await api.listProjects(); - await enterProject(projects[0] ?? null); - }); +async function renameDocFlow(): Promise<void> { + if (!currentPath || !currentProject) { + return; + } + const project = currentProject; + const from = currentPath.slice(project.length + 1); + const entered = (await askText({ title: 'Rename document', initial: from, confirmLabel: 'Rename' }))?.trim(); + if (!entered || entered === from) { + return; + } + const to = /\.(md|markdown|txt)$/i.test(entered) ? entered : `${entered}.md`; + await attempt(async () => { + await api.moveDoc(`${project}/${from}`, { path: to }); + await loadProject(project); + openDocument(`${project}/${to}`, 'replace'); + toast(`Renamed to ${to}`); }); +} - document.querySelector('#doc-new')!.addEventListener('click', () => { - if (!currentProject) { - alert('Create a project first.'); - return; - } - const project = currentProject; - const entered = prompt('Document name (e.g. notes.md or specs/plan.md)')?.trim(); - if (!entered) { - return; - } - const path = /\.(md|markdown|txt)$/i.test(entered) ? entered : `${entered}.md`; - void attempt(async () => { - await api.createDoc(project, path); - await loadProject(project); - openDocument(`${project}/${path}`); - }); +async function moveDocFlow(): Promise<void> { + if (!currentPath || !currentProject) { + return; + } + const source = currentPath; + const others = projects.filter((name) => name !== currentProject); + if (others.length === 0) { + toast('There is no other project to move to.', { tone: 'error' }); + return; + } + const target = await askChoice({ + title: 'Move to project', + hint: 'Pick the project to move this document into.', + options: others.map((name) => ({ value: name, label: name })), }); + if (!target) { + return; + } + await attempt(async () => { + const rel = source.slice(source.indexOf('/') + 1); + await api.moveDoc(source, { project: target }); + await enterProject(target, `${target}/${rel}`); + toast(`Moved to ${target}`); + }); +} - document.querySelector('#doc-rename')!.addEventListener('click', () => { - if (!currentPath || !currentProject) { - return; - } - const project = currentProject; - const from = currentPath.slice(project.length + 1); - const entered = prompt('Rename document', from)?.trim(); - if (!entered || entered === from) { - return; - } - const to = /\.(md|markdown|txt)$/i.test(entered) ? entered : `${entered}.md`; - void attempt(async () => { - await api.moveDoc(`${project}/${from}`, { path: to }); - await loadProject(project); - openDocument(`${project}/${to}`, 'replace'); - }); +async function deleteDocFlow(): Promise<void> { + if (!currentPath || !currentProject) { + return; + } + const path = currentPath; + const project = currentProject; + const name = path.slice(project.length + 1); + const ok = await askConfirm({ + title: `Delete “${name}”?`, + body: 'This cannot be undone.', + confirmLabel: 'Delete', + danger: true, + }); + if (!ok) { + return; + } + await attempt(async () => { + await api.deleteDoc(path); + await enterProject(project); + toast(`Deleted ${name}`); }); +} - document.querySelector('#doc-move')!.addEventListener('click', () => { - if (!currentPath || !currentProject) { - return; - } - const source = currentPath; - const others = projects.filter((name) => name !== currentProject); - if (others.length === 0) { - alert('There is no other project to move to.'); - return; - } - const target = prompt(`Move to project (${others.join(', ')})`)?.trim(); - if (!target || target === currentProject) { - return; +/** A plain-DOM dropdown: toggle button + positioned list, closes on outside click / Escape. */ +function wireMenu(menuSelector: string): void { + const menu = document.querySelector(menuSelector)! as HTMLElement; + const toggle = menu.querySelector('.menu-toggle')! as HTMLButtonElement; + const list = menu.querySelector('.menu-list')! as HTMLElement; + const setOpen = (open: boolean) => { + list.hidden = !open; + toggle.setAttribute('aria-expanded', String(open)); + }; + toggle.addEventListener('click', (event) => { + event.preventDefault(); + setOpen(list.hidden); + }); + list.addEventListener('click', () => setOpen(false)); + document.addEventListener('click', (event) => { + if (!menu.contains(event.target as Node)) { + setOpen(false); } - if (!projects.includes(target)) { - alert(`No such project: "${target}"`); - return; + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + setOpen(false); } - void attempt(async () => { - const rel = source.slice(source.indexOf('/') + 1); - await api.moveDoc(source, { project: target }); - await enterProject(target, `${target}/${rel}`); - }); }); +} - document.querySelector('#doc-delete')!.addEventListener('click', () => { - if (!currentPath || !currentProject) { - return; +function wireCrud() { + wireMenu('#project-menu'); + wireMenu('#doc-menu'); + + projectSelect.addEventListener('change', async () => { + await loadProject(projectSelect.value); + if (docs[0]) { + openDocument(docs[0]); + } else { + closeDocument(); + writeUrlState({ doc: null, project: currentProject, comment: null }, { push: true }); } - const path = currentPath; - const project = currentProject; - if (!confirm(`Delete "${path}"?`)) { + }); + + document.querySelector('#project-mcp')!.addEventListener('click', () => { + if (!currentProject) { return; } - void attempt(async () => { - await api.deleteDoc(path); - await enterProject(project); - }); + // Humans can't contain "/", so <me>/claude is a valid owner/agent suggestion. + void openMcpConfig(currentProject, `${user.name}/claude`); }); + + document.querySelector('#project-new')!.addEventListener('click', () => void newProjectFlow()); + document.querySelector('#project-rename')!.addEventListener('click', () => void renameProjectFlow()); + document.querySelector('#project-delete')!.addEventListener('click', () => void deleteProjectFlow()); + document.querySelector('#doc-new')!.addEventListener('click', () => void newDocFlow()); + document.querySelector('#doc-rename')!.addEventListener('click', () => void renameDocFlow()); + document.querySelector('#doc-move')!.addEventListener('click', () => void moveDocFlow()); + document.querySelector('#doc-delete')!.addEventListener('click', () => void deleteDocFlow()); + + // A project created (or removed) in another tab should appear on return. + window.addEventListener('focus', () => void refreshProjects()); } async function init() { @@ -561,6 +756,7 @@ async function init() { async function main() { const name = storedUser() ?? (await promptForUser()); user = withColors(name); + appEl.hidden = false; renderMe(); await init(); } diff --git a/src/client/preview.ts b/src/client/preview.ts index 523e1bb..47a65f7 100644 --- a/src/client/preview.ts +++ b/src/client/preview.ts @@ -1,12 +1,16 @@ import MarkdownIt from 'markdown-it'; import mermaid from 'mermaid'; import type * as Y from 'yjs'; +import type { ViewMode } from './url-state'; /** - * Live markdown preview pane: renders the shared text with markdown-it - * (debounced, so typing and remote edits don't thrash it) and turns - * ```mermaid fences into rendered diagrams. Raw HTML in documents stays - * escaped — the preview shows markdown, it doesn't execute it. + * View-mode layout + live markdown preview. The header's Edit|Both|Read + * segmented control drives this: Edit shows the editor only, Both splits the + * editor and the rendered preview, Read shows the full-width preview alone. + * The preview renders the shared text with markdown-it (debounced, so typing + * and remote edits don't thrash it) and turns ```mermaid fences into rendered + * diagrams. Raw HTML stays escaped — the preview shows markdown, it doesn't + * execute it. */ const RENDER_DEBOUNCE_MS = 300; @@ -27,20 +31,30 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => { return defaultFence(tokens, idx, options, env, self); }; +const editorHost = document.querySelector('#editor')! as HTMLElement; const pane = document.querySelector('#preview')! as HTMLElement; -const toggleButton = document.querySelector('#preview-toggle')! as HTMLButtonElement; +const modeButtons: Record<ViewMode, HTMLButtonElement> = { + edit: document.querySelector('#mode-edit')! as HTMLButtonElement, + both: document.querySelector('#mode-both')! as HTMLButtonElement, + read: document.querySelector('#mode-read')! as HTMLButtonElement, +}; -let enabled = false; // sticky across document switches +let mode: ViewMode = 'edit'; // sticky across document switches let renderSeq = 0; let currentApply: (() => void) | null = null; -let notifyChange: ((enabled: boolean) => void) | null = null; +let notifyChange: ((mode: ViewMode) => void) | null = null; + +/** Whether the preview pane is showing (Both or Read). */ +function previewShown(): boolean { + return mode !== 'edit'; +} -/** Apply externally-driven state (URL/boot) — applies without notifying back. */ -export function setPreviewEnabled(on: boolean): void { - if (enabled === on) { +/** Apply externally-driven mode (URL/boot) — applies without notifying back. */ +export function setMode(next: ViewMode): void { + if (mode === next) { return; } - enabled = on; + mode = next; currentApply?.(); } @@ -72,12 +86,12 @@ async function renderInto(host: HTMLElement, text: string): Promise<void> { } } -export function wirePreview(ytext: Y.Text, onChange?: (enabled: boolean) => void): () => void { +export function wirePreview(ytext: Y.Text, onChange?: (mode: ViewMode) => void): () => void { let timer: ReturnType<typeof setTimeout> | null = null; notifyChange = onChange ?? null; const render = () => { - if (!enabled) { + if (!previewShown()) { return; } void renderInto(pane, ytext.toString()); @@ -94,29 +108,42 @@ export function wirePreview(ytext: Y.Text, onChange?: (enabled: boolean) => void }; const applyState = () => { - pane.hidden = !enabled; - toggleButton.classList.toggle('active', enabled); - if (enabled) { + pane.hidden = !previewShown(); + editorHost.hidden = mode === 'read'; + for (const [name, button] of Object.entries(modeButtons)) { + button.classList.toggle('active', name === mode); + } + if (previewShown()) { render(); } else { pane.innerHTML = ''; } }; - const onToggle = () => { - enabled = !enabled; + const select = (next: ViewMode) => { + if (mode === next) { + return; + } + mode = next; applyState(); - notifyChange?.(enabled); + notifyChange?.(mode); }; + const listeners = (Object.keys(modeButtons) as ViewMode[]).map((name) => { + const handler = () => select(name); + modeButtons[name].addEventListener('click', handler); + return [name, handler] as const; + }); + ytext.observe(scheduleRender); - toggleButton.addEventListener('click', onToggle); currentApply = applyState; - applyState(); // respect the sticky toggle when switching documents + applyState(); // respect the sticky mode when switching documents return () => { ytext.unobserve(scheduleRender); - toggleButton.removeEventListener('click', onToggle); + for (const [name, handler] of listeners) { + modeButtons[name].removeEventListener('click', handler); + } if (timer) { clearTimeout(timer); } @@ -124,5 +151,6 @@ export function wirePreview(ytext: Y.Text, onChange?: (enabled: boolean) => void notifyChange = null; pane.innerHTML = ''; pane.hidden = true; + editorHost.hidden = false; }; } diff --git a/src/client/styles.css b/src/client/styles.css index 07f2f42..5d66539 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1,3 +1,17 @@ +:root { + --ink: #1c1b22; /* text */ + --ink-soft: #55525e; /* secondary text */ + --ink-faint: #8b8794; /* captions, placeholders */ + --paper: #fbfaf8; /* app background */ + --panel: #ffffff; /* cards, editor surface */ + --line: #e4e1dc; /* borders */ + --accent: #6a53d0; /* agent violet — also the suggestion/search color */ + --accent-soft: #efebfb; + --human: #177e5b; /* human presence green */ + --danger: #b3261e; + --danger-soft: #fbeae8; +} + * { box-sizing: border-box; margin: 0; @@ -5,6 +19,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--ink); height: 100vh; overflow: hidden; } @@ -14,12 +29,16 @@ body { height: 100vh; } +#app[hidden] { + display: none; +} + #sidebar { width: 220px; flex-shrink: 0; - border-right: 1px solid #ddd; + border-right: 1px solid var(--line); padding: 12px; - background: #fafafa; + background: var(--paper); display: flex; flex-direction: column; } @@ -31,6 +50,7 @@ body { #project-bar { display: flex; + align-items: center; gap: 4px; margin-bottom: 10px; } @@ -41,22 +61,109 @@ body { padding: 4px 6px; font: inherit; font-size: 13px; - border: 1px solid #ddd; - border-radius: 4px; - background: #fff; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); } -#project-bar button { - padding: 2px 6px; +#project-new { + padding: 4px 8px; font-size: 12px; - border: 1px solid #ddd; - border-radius: 4px; - background: #fff; + white-space: nowrap; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--ink-soft); + cursor: pointer; +} + +#project-new:hover { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +/* ── plain-DOM dropdown menu (project ⋯, document ⋯) ─────────────────────── */ + +.menu { + position: relative; +} + +.menu[hidden] { + display: none; +} + +.menu-toggle { + padding: 3px 8px; + font-size: 14px; + line-height: 1; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--ink-soft); cursor: pointer; } -#project-bar button:hover { - background: #f0f0f0; +.menu-toggle:hover, +.menu-toggle[aria-expanded='true'] { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +.menu-list { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 20; + min-width: 180px; + margin: 0; + padding: 4px; + list-style: none; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 8px 24px #0000001f; +} + +.menu-list[hidden] { + display: none; +} + +.menu-list li { + list-style: none; +} + +.menu-list button { + display: block; + width: 100%; + text-align: left; + padding: 6px 10px; + font-size: 12px; + border: none; + border-radius: 5px; + background: none; + color: var(--ink); + cursor: pointer; + white-space: nowrap; +} + +.menu-list button:hover { + background: var(--paper); +} + +.menu-list button.danger { + color: var(--danger); +} + +.menu-list button.danger:hover { + background: var(--danger-soft); +} + +.menu-divider { + height: 1px; + margin: 4px 2px; + background: var(--line); } #doc-new { @@ -64,16 +171,22 @@ body { margin-bottom: 8px; padding: 4px 6px; font-size: 12px; - border: 1px dashed #ccc; - border-radius: 4px; + border: 1px dashed var(--line); + border-radius: 6px; background: transparent; - color: #555; + color: var(--ink-soft); cursor: pointer; text-align: left; } #doc-new:hover { - background: #f0f0f0; + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +#doc-new[hidden] { + display: none; } #doc-search { @@ -81,11 +194,15 @@ body { box-sizing: border-box; padding: 6px 10px; margin-bottom: 8px; - border: 1px solid #ccc; + border: 1px solid var(--line); border-radius: 8px; font-size: 12px; } +#doc-search[hidden] { + display: none; +} + #doc-list { list-style: none; padding: 0; @@ -176,8 +293,8 @@ body { display: flex; align-items: center; justify-content: center; - background: #fafafaee; - z-index: 10; + background: var(--paper); + z-index: 30; } #login[hidden] { @@ -187,47 +304,57 @@ body { #login-form { display: flex; flex-direction: column; - gap: 10px; - width: 280px; - padding: 24px; - border: 1px solid #ddd; - border-radius: 10px; - background: #fff; + gap: 12px; + width: 320px; + padding: 28px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); box-shadow: 0 8px 30px #00000014; + text-align: center; } -#login-form h2 { - font-size: 16px; +.login-brand { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--accent); } -.login-hint { - font-size: 12px; - color: #666; +.login-tagline { + font-size: 13px; + line-height: 1.5; + color: var(--ink-soft); } #login-name { padding: 8px 10px; - border: 1px solid #ccc; - border-radius: 6px; + border: 1px solid var(--line); + border-radius: 8px; font-size: 14px; + text-align: center; } #login-error { font-size: 12px; - color: #c2352b; + color: var(--danger); } #login-form button { - padding: 8px; + padding: 9px; border: none; - border-radius: 6px; - background: #2f7fd1; + border-radius: 8px; + background: var(--accent); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; } +#login-form button:hover { + filter: brightness(1.05); +} + #doc-list li { padding: 6px 8px; border-radius: 6px; @@ -237,11 +364,12 @@ body { } #doc-list li:hover { - background: #eee; + background: var(--accent-soft); } #doc-list li.active { - background: #dbe9ff; + background: var(--accent-soft); + color: var(--accent); font-weight: 600; } @@ -257,60 +385,127 @@ body { align-items: center; gap: 12px; padding: 10px 16px; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid var(--line); } -#doc-title { - font-weight: 600; +#header[hidden] { + display: none; +} + +#doc-identity { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin-right: auto; +} + +#status-dot { + flex-shrink: 0; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--ink-faint); +} + +#status-dot[data-status='connected'] { + background: var(--human); +} + +#status-dot[data-status='connecting'] { + background: var(--ink-faint); +} + +#status-dot[data-status='disconnected'] { + background: var(--danger); +} + +#breadcrumb { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; font-size: 14px; } -#status { - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; - background: #eee; - color: #666; +#crumb-project { + color: var(--ink-soft); + white-space: nowrap; } -#status[data-status='connected'] { - background: #d9f2dd; - color: #1a7f37; +.crumb-sep { + color: var(--ink-faint); +} + +#crumb-title { + font-weight: 600; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } #presence { display: flex; - gap: 6px; - margin-left: auto; + align-items: center; } -#history-open, -#versions-open, -#doc-rename, -#doc-move, -#doc-delete { +#presence .avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; font-size: 11px; - padding: 3px 10px; - border: 1px solid #ccc; - border-radius: 10px; - background: #fff; - color: #666; + font-weight: 600; + color: #fff; + border: 2px solid var(--panel); + box-shadow: 0 0 0 0.5px #00000022; +} + +/* Overlapping stack: each avatar tucks under the previous one. */ +#presence .avatar + .avatar { + margin-left: -8px; +} + +#presence .avatar.human { + border-radius: 50%; +} + +#presence .avatar.agent { + border-radius: 6px; +} + +/* ── Edit | Both | Read segmented control ────────────────────────────────── */ + +#mode-toggle { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} + +#mode-toggle button { + padding: 4px 12px; + font-size: 12px; + border: none; + border-left: 1px solid var(--line); + background: var(--panel); + color: var(--ink-soft); cursor: pointer; - white-space: nowrap; } -#history-open:hover, -#versions-open:hover, -#doc-rename:hover, -#doc-move:hover { - background: #eee; - color: #333; +#mode-toggle button:first-child { + border-left: none; } -#doc-delete:hover { - background: #fde8e8; - border-color: #c0392b; - color: #c0392b; +#mode-toggle button:hover { + background: var(--paper); +} + +#mode-toggle button.active { + background: var(--accent); + color: #fff; } #history { @@ -635,14 +830,15 @@ body { .mcp-block pre { margin: 0; padding: 8px 10px; - border: 1px solid #eee; + border: 1px solid var(--line); border-radius: 6px; - background: #fafafa; + background: var(--paper); font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 11px; line-height: 1.5; - overflow-x: auto; - white-space: pre; + /* Wrap long command lines instead of clipping them off the panel edge. */ + white-space: pre-wrap; + overflow-wrap: anywhere; } .mcp-hint { @@ -680,42 +876,29 @@ body { min-height: 0; } +#workspace[hidden] { + display: none; +} + #editor { flex: 1; overflow: hidden; min-width: 0; } -#preview-toggle { - font-size: 11px; - padding: 3px 10px; - border: 1px solid #ccc; - border-radius: 10px; - background: #fff; - color: #666; - cursor: pointer; - white-space: nowrap; -} - -#preview-toggle:hover { - background: #eee; - color: #333; -} - -#preview-toggle.active { - background: #dbe9ff; - border-color: #2f7fd1; - color: #2f7fd1; +#editor[hidden] { + display: none; } #preview { flex: 1; min-width: 0; overflow-y: auto; - border-left: 1px solid #ddd; - padding: 16px 24px; - font-size: 14px; - line-height: 1.6; + border-left: 1px solid var(--line); + /* Cap the reading column at 72ch and center it. */ + padding: 32px max(24px, calc((100% - 72ch) / 2)); + font-size: 15px; + line-height: 1.7; } #preview[hidden] { @@ -793,26 +976,9 @@ body { text-align: left; } -#comment-add { - font-size: 11px; - padding: 3px 10px; - border: 1px solid #ccc; - border-radius: 10px; - background: #fff; - color: #666; - cursor: pointer; - white-space: nowrap; -} - -#comment-add:hover { - background: #eee; - color: #333; -} - #comment-add.nudge { animation: comment-nudge 0.3s ease-in-out 2; - border-color: #c2352b; - color: #c2352b; + color: var(--danger); } @keyframes comment-nudge { @@ -1033,13 +1199,27 @@ body { #editor .cm-editor { height: 100%; - font-size: 14px; + font-size: 15px; } #editor .cm-scroller { overflow: auto; - font-family: 'SF Mono', Menlo, Consolas, monospace; - line-height: 1.6; + /* Prose reads in a proportional face; fenced code stays monospaced via the + markdown HighlightStyle (tags.monospace). */ + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.7; +} + +/* No line numbers for prose. */ +#editor .cm-gutters { + display: none; +} + +/* Comfortable centered reading column, matching the preview. */ +#editor .cm-content { + max-width: 72ch; + margin: 0 auto; + padding: 24px 16px 40vh; } .mdio-remote-edit { @@ -1223,3 +1403,244 @@ body { .mdio-suggest-focused { background: #6a53d033; } + +/* ── in-app dialogs (askText / askChoice / askConfirm) ───────────────────── */ + +#dialog { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: #1c1b2233; + z-index: 25; +} + +#dialog[hidden] { + display: none; +} + +#dialog-panel { + display: flex; + flex-direction: column; + gap: 12px; + width: min(420px, 92vw); + padding: 20px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + box-shadow: 0 12px 40px #00000024; +} + +#dialog-title { + font-size: 15px; + font-weight: 600; + color: var(--ink); +} + +#dialog-body { + font-size: 13px; + line-height: 1.5; + color: var(--ink-soft); +} + +#dialog-body[hidden] { + display: none; +} + +#dialog-input { + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + font-size: 14px; + font-family: inherit; +} + +#dialog-input[hidden] { + display: none; +} + +#dialog-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +#dialog-choices { + display: flex; + flex-direction: column; + gap: 6px; +} + +#dialog-choices[hidden] { + display: none; +} + +.dialog-choice { + text-align: left; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--ink); + font-size: 13px; + cursor: pointer; +} + +.dialog-choice:hover { + background: var(--accent-soft); + border-color: var(--accent); +} + +#dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +#dialog-actions button { + padding: 7px 14px; + border-radius: 8px; + font-size: 13px; + cursor: pointer; +} + +#dialog-cancel { + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink-soft); +} + +#dialog-cancel:hover { + background: var(--paper); +} + +#dialog-confirm { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + font-weight: 600; +} + +#dialog-confirm:hover { + filter: brightness(1.05); +} + +#dialog-confirm[hidden] { + display: none; +} + +#dialog-confirm.danger { + background: var(--danger); + border-color: var(--danger); +} + +/* ── toasts ──────────────────────────────────────────────────────────────── */ + +#toast-tray { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + z-index: 40; + pointer-events: none; +} + +.toast { + max-width: 80vw; + padding: 9px 16px; + border-radius: 8px; + font-size: 13px; + color: #fff; + background: var(--ink); + box-shadow: 0 6px 20px #00000026; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.toast-error { + background: var(--danger); +} + +/* ── empty states ───────────────────────────────────────────────────────── */ + +#empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +#empty-state[hidden] { + display: none; +} + +.empty-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: 380px; + text-align: center; +} + +.empty-box h2 { + font-size: 20px; + font-weight: 700; + color: var(--ink); +} + +.empty-box p { + font-size: 14px; + line-height: 1.5; + color: var(--ink-soft); +} + +.empty-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + margin-top: 8px; +} + +.empty-btn { + padding: 9px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.empty-btn.primary { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; +} + +.empty-btn.primary:hover { + filter: brightness(1.05); +} + +.empty-btn.ghost { + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); +} + +.empty-btn.ghost:hover { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} diff --git a/src/client/url-state.ts b/src/client/url-state.ts index 776ad82..2696b82 100644 --- a/src/client/url-state.ts +++ b/src/client/url-state.ts @@ -1,20 +1,23 @@ /** * Navigation state: the document is the URL path (/project/notes/plan.md) and * a bare project page is /project, so links are stable, shareable paths. View - * state stays in the hash as query-style params (#preview=1&comment=c-xyz). + * state stays in the hash as query-style params (#mode=both&comment=c-xyz). * * Document switches push a history entry (back/forward navigates documents); - * view-state changes (preview, comment focus, filters) replace the current - * entry so they survive reload and sharing without polluting history. + * view-state changes (mode, comment focus, filters) replace the current entry + * so they survive reload and sharing without polluting history. * pushState/replaceState don't fire popstate, so only user navigation * (back/forward, hash edits) triggers the listener — no self-echo guard needed. */ +/** Editor layout: editor only, editor + preview split, or preview only. */ +export type ViewMode = 'edit' | 'both' | 'read'; + export interface UrlState { doc: string | null; /** The doc's project (first path segment), or a doc-less project page. */ project: string | null; - preview: boolean; + mode: ViewMode; comment: string | null; resolved: boolean; } @@ -33,6 +36,10 @@ function encodePath(path: string): string { return `/${path.split('/').map(encodeURIComponent).join('/')}`; } +function readMode(raw: string | null): ViewMode { + return raw === 'both' || raw === 'read' ? raw : 'edit'; +} + export function readUrlState(): UrlState { const params = new URLSearchParams(location.hash.slice(1)); const path = decodePath(location.pathname); @@ -40,7 +47,7 @@ export function readUrlState(): UrlState { return { doc: isDoc ? path : null, project: isDoc ? path!.split('/')[0]! : path, - preview: params.get('preview') === '1', + mode: readMode(params.get('mode')), comment: params.get('comment'), resolved: params.get('resolved') === '1', }; @@ -54,8 +61,8 @@ export function writeUrlState(partial: Partial<UrlState>, { push = false } = {}) next.project = partial.doc.split('/')[0]!; } const params = new URLSearchParams(); - if (next.preview) { - params.set('preview', '1'); + if (next.mode !== 'edit') { + params.set('mode', next.mode); } if (next.comment) { params.set('comment', next.comment); diff --git a/src/client/versions.ts b/src/client/versions.ts index d7db5f4..31e9231 100644 --- a/src/client/versions.ts +++ b/src/client/versions.ts @@ -1,4 +1,5 @@ import { createSnapshot, listSnapshots, restoreSnapshot, type Snapshot } from './api'; +import { askConfirm, toast } from './dialogs'; /** * Versions overlay: save named checkpoints of a document and restore any of @@ -12,6 +13,7 @@ const titleEl = document.querySelector('#versions-title')!; const listEl = document.querySelector('#versions-list')!; const form = document.querySelector('#versions-form')! as HTMLFormElement; const labelInput = document.querySelector('#versions-label')! as HTMLInputElement; +const saveButton = document.querySelector('#versions-save')! as HTMLButtonElement; const closeButton = document.querySelector('#versions-close')! as HTMLButtonElement; let ctx: { path: string; author: string } | null = null; @@ -59,18 +61,22 @@ async function doRestore(snapshot: Snapshot): Promise<void> { if (!ctx) { return; } - if ( - !confirm( - `Restore "${snapshot.label}"? The current text is replaced with that version. ` + - `This is itself a normal edit, so you can undo it or restore a newer version.`, - ) - ) { + const ok = await askConfirm({ + title: `Restore “${snapshot.label}”?`, + body: + 'The current text is replaced with that version. This is itself a normal ' + + 'edit, so you can undo it or restore a newer version.', + confirmLabel: 'Restore', + }); + if (!ok) { return; } try { await restoreSnapshot(ctx.path, snapshot.id, ctx.author); + toast(`Restored “${snapshot.label}”`); + await refresh(); } catch (error) { - alert(`Restore failed: ${error instanceof Error ? error.message : String(error)}`); + toast(`Restore failed: ${error instanceof Error ? error.message : String(error)}`, { tone: 'error' }); } } @@ -85,12 +91,16 @@ form.addEventListener('submit', (event) => { } const { path, author } = ctx; void (async () => { + saveButton.disabled = true; try { await createSnapshot(path, label, author); labelInput.value = ''; await refresh(); + toast(`Saved “${label}”`); } catch (error) { - alert(`Save failed: ${error instanceof Error ? error.message : String(error)}`); + toast(`Save failed: ${error instanceof Error ? error.message : String(error)}`, { tone: 'error' }); + } finally { + saveButton.disabled = false; } })(); }); diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index b2b03ed..e845a7d 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, expect, test } from 'bun:test'; import { join } from 'node:path'; import { chromium, type Browser, type Page } from 'playwright'; -import { apiCreateDoc, connectPeer, startTestServer, waitFor } from './helpers'; +import { apiCreateDoc, connectPeer, startTestServer } from './helpers'; import type { MdioServer } from '../src/server/index'; import { AgentClient } from './mcp-client'; @@ -36,6 +36,34 @@ function waitForText(selector: string, text: string, timeoutMs = 10_000) { ); } +/** The document identity now lives in the URL path, not a header label. */ +function waitForPath(path: string) { + return page.waitForFunction((needle) => location.pathname === needle, `/${path}`); +} + +/** A peer is present when an avatar carries its name in the title tooltip. */ +function waitForPresence(name: string) { + return page.waitForFunction( + (needle) => + [...document.querySelectorAll('#presence .avatar')].some( + (el) => el.getAttribute('title') === needle, + ), + name, + ); +} + +/** Open the document ⋯ menu and click one of its items (comment/history/rename/…). */ +async function docMenu(itemId: string) { + await page.click('#doc-menu-toggle'); + await page.click(`#${itemId}`); +} + +/** Open the project ⋯ menu and click one of its items (rename/connect/delete). */ +async function projectMenu(itemId: string) { + await page.click('#project-menu-toggle'); + await page.click(`#${itemId}`); +} + test( 'two MCP agents and a human edit the same document concurrently without losing anything', async () => { @@ -47,9 +75,9 @@ test( await alice.call('open_document', { path: 'demo.md' }); await bob.call('open_document', { path: 'demo.md' }); - // Human sees both agents in the presence bar, marked as agents of their owner. - await waitForText('#presence', '🤖 plosson/alice'); - await waitForText('#presence', '🤖 plosson/bob'); + // Human sees both agents in the presence stack, marked as agents of their owner. + await waitForPresence('plosson/alice'); + await waitForPresence('plosson/bob'); // All three edit at the same time. const humanEdits = (async () => { @@ -126,7 +154,7 @@ test( color: '#8a4bbf', colorLight: '#8a4bbf33', }); - await waitForText('#presence', 'Carol'); // awareness propagated to the browser + await waitForPresence('Carol'); // awareness propagated to the browser carol.text.insert(carol.text.length, '\nCAROL: watch this line appear highlighted\n'); // Badge with the author's name shows up on the inserted range… @@ -165,7 +193,7 @@ test( test( 'history mode replays the edit log: scrub back to the seed, play forward again', async () => { - await page.click('#history-open'); + await docMenu('history-open'); await page.waitForSelector('#history:not([hidden])'); expect(await page.textContent('#history-title')).toBe('main/demo.md — history'); @@ -207,7 +235,7 @@ test( const at = view.state.doc.toString().indexOf('First note'); view.dispatch({ selection: { anchor: at, head: at + 'First note'.length } }); }); - await page.click('#comment-add'); + await docMenu('comment-add'); await page.fill( '#comments-list textarea[data-draft="new-comment"]', 'HUMAN: is this note still valid, @plosson/alice?', @@ -265,7 +293,7 @@ test( // Empty selection: the add button nudges instead of opening a composer. await selectInEditor('ALICE: second paragraph', true); - await page.click('#comment-add'); + await docMenu('comment-add'); await page.waitForFunction(() => document.querySelector('#comment-add')?.classList.contains('nudge'), ); @@ -273,7 +301,7 @@ test( // Mention autocomplete: type "@plo", pick the second candidate with the keyboard. await selectInEditor('second paragraph'); - await page.click('#comment-add'); + await docMenu('comment-add'); await page.fill('textarea[data-draft="new-comment"]', 'needs polish @plo'); await page.waitForSelector('.mention-dropdown:not([hidden]) .mention-option'); const options = await page.locator('.mention-option').allTextContents(); @@ -335,7 +363,7 @@ test( test( 'markdown preview renders live, including mermaid diagrams (and survives invalid ones)', async () => { - await page.click('#preview-toggle'); + await page.click('#mode-both'); await page.waitForSelector('#preview:not([hidden])'); await page.waitForFunction(() => document.querySelector('#preview h1')?.textContent?.includes('Demo document'), @@ -365,7 +393,7 @@ test( expect(await page.locator('#preview pre.mermaid svg').count()).toBeGreaterThanOrEqual(1); // Leave the pane off so later tests see the default layout. - await page.click('#preview-toggle'); + await page.click('#mode-edit'); await page.waitForSelector('#preview', { state: 'hidden' }); }, 45_000, @@ -376,12 +404,11 @@ test( async () => { // Switching documents pushes a history entry with the doc as the path. await page.click('li[data-path="main/other.md"]'); - await waitForText('#doc-title', 'main/other.md'); - await page.waitForFunction(() => location.pathname === '/main/other.md'); + await waitForPath('main/other.md'); - // View state (preview) is reflected in the hash without new history entries. - await page.click('#preview-toggle'); - await page.waitForFunction(() => location.hash.includes('preview=1')); + // View state (mode) is reflected in the hash without new history entries. + await page.click('#mode-both'); + await page.waitForFunction(() => location.hash.includes('mode=both')); // Creating a comment focuses it and deep-links it in the hash. await page.evaluate(() => { @@ -390,7 +417,7 @@ test( const at = view.state.doc.toString().indexOf('Other'); view.dispatch({ selection: { anchor: at, head: at + 'Other'.length } }); }); - await page.click('#comment-add'); + await docMenu('comment-add'); await page.fill('textarea[data-draft="new-comment"]', 'deep-linkable thread'); await page.click('#comments-list .comment-btn.primary'); await page.waitForFunction(() => location.hash.includes('comment=c-')); @@ -398,15 +425,14 @@ test( // Reload: same document, preview open, same thread focused. await page.reload(); await page.waitForSelector('.cm-content'); - await waitForText('#doc-title', 'main/other.md'); + await waitForPath('main/other.md'); await page.waitForSelector('#preview:not([hidden])'); await page.waitForSelector('.comment-card.focused'); expect(await page.textContent('.comment-card.focused')).toInclude('deep-linkable thread'); // Back returns to the previous document (and its view state: preview off). await page.goBack(); - await waitForText('#doc-title', 'main/demo.md'); - await page.waitForFunction(() => location.pathname === '/main/demo.md'); + await waitForPath('main/demo.md'); await page.waitForSelector('#preview', { state: 'hidden' }); }, 30_000, @@ -434,21 +460,20 @@ test( await page.waitForSelector('.comment-card.focused'); // A direct path URL loads that document with its view state applied. - await page.goto(`${server.url}/main/other.md#preview=1`); + await page.goto(`${server.url}/main/other.md#mode=both`); await page.waitForSelector('.cm-content'); - await waitForText('#doc-title', 'main/other.md'); + await waitForPath('main/other.md'); await page.waitForSelector('#preview:not([hidden])'); // An unknown document falls back to the first doc and normalizes the path. await page.goto(`${server.url}/does-not-exist.md`); await page.waitForSelector('.cm-content'); - await waitForText('#doc-title', 'main/demo.md'); - await page.waitForFunction(() => location.pathname === '/main/demo.md'); + await waitForPath('main/demo.md'); // A stale comment id is ignored: page loads, nothing focused, no errors. await page.goto(`${server.url}/main/demo.md#comment=c-deleted-long-ago`); await page.waitForSelector('.cm-content'); - await waitForText('#doc-title', 'main/demo.md'); + await waitForPath('main/demo.md'); expect(await page.locator('.comment-card.focused').count()).toBe(0); }, 30_000, @@ -503,7 +528,7 @@ test( await page.goto(`${server.url}/specs/plan.md`); await page.waitForSelector('.cm-content'); - await waitForText('#doc-title', 'specs/plan.md'); + await waitForPath('specs/plan.md'); // Sidebar is scoped: this project's docs (sans prefix), none of main's. const selected = () => @@ -514,13 +539,11 @@ test( // Switching projects opens that project's first document. await page.selectOption('#project-select', 'main'); - await waitForText('#doc-title', 'main/demo.md'); - await page.waitForFunction(() => location.pathname === '/main/demo.md'); + await waitForPath('main/demo.md'); // Back crosses the project boundary and the switcher follows. await page.goBack(); - await waitForText('#doc-title', 'specs/plan.md'); - await page.waitForFunction(() => location.pathname === '/specs/plan.md'); + await waitForPath('specs/plan.md'); expect(await selected()).toBe('specs'); }, 30_000, @@ -529,97 +552,87 @@ test( test( 'humans CRUD projects and documents from the UI, with cancels and errors handled', async () => { - type DialogHandler = (dialog: import('playwright').Dialog) => Promise<void>; - const queue: DialogHandler[] = []; - const alerts: string[] = []; - const onDialog = async (dialog: import('playwright').Dialog) => { - if (dialog.type() === 'alert') { - alerts.push(dialog.message()); - await dialog.dismiss(); - return; - } - const handler = queue.shift(); - await (handler ? handler(dialog) : dialog.dismiss()); - }; - page.on('dialog', onDialog); const selected = () => page.evaluate(() => (document.querySelector('#project-select') as HTMLSelectElement).value); - try { - // Starting point: the specs project from the previous test. - await page.goto(`${server.url}/specs/plan.md`); - await page.waitForSelector('.cm-content'); - - // Cancelling the prompt changes nothing. - queue.push((dialog) => dialog.dismiss()); - await page.click('#project-new'); - expect(await selected()).toBe('specs'); - expect(await page.evaluate(() => location.pathname)).toBe('/specs/plan.md'); - - // A reserved project name is rejected and surfaced as an alert. - queue.push((dialog) => dialog.accept('api')); - await page.click('#project-new'); - await waitFor(() => alerts.some((message) => message.includes('reserved')), { - label: 'reserved-name error alert', - }); - expect(await selected()).toBe('specs'); - - // Create a project: URL becomes its page, sidebar is empty. - queue.push((dialog) => dialog.accept('research')); - await page.click('#project-new'); - await page.waitForFunction(() => location.pathname === '/research'); - expect(await selected()).toBe('research'); - expect(await page.locator('#doc-list li').count()).toBe(0); - - // Create a document — the .md extension is implied. - queue.push((dialog) => dialog.accept('ideas')); - await page.click('#doc-new'); - await page.waitForFunction(() => location.pathname === '/research/ideas.md'); - await waitForText('#doc-title', 'research/ideas.md'); - await page.locator('.cm-content').click(); - await page.keyboard.type('# Ideas from the UI'); - await waitForText('.cm-content', '# Ideas from the UI'); - - // Rename it: URL updates, content survives. - queue.push((dialog) => dialog.accept('brainstorm.md')); - await page.click('#doc-rename'); - await page.waitForFunction(() => location.pathname === '/research/brainstorm.md'); - await waitForText('#doc-title', 'research/brainstorm.md'); - await waitForText('.cm-content', '# Ideas from the UI'); - - // Move it to another project: everything follows. - queue.push((dialog) => dialog.accept('main')); - await page.click('#doc-move'); - await page.waitForFunction(() => location.pathname === '/main/brainstorm.md'); - await waitForText('#doc-title', 'main/brainstorm.md'); - await waitForText('.cm-content', '# Ideas from the UI'); - expect(await selected()).toBe('main'); - - // Delete it: the UI falls back to the project's first document. - queue.push((dialog) => dialog.accept()); - await page.click('#doc-delete'); - await page.waitForFunction(() => location.pathname === '/main/demo.md'); - expect(await page.locator('li[data-path="main/brainstorm.md"]').count()).toBe(0); - - // Rename the (now empty) research project. - await page.selectOption('#project-select', 'research'); - await page.waitForFunction(() => location.pathname === '/research'); - queue.push((dialog) => dialog.accept('lab')); - await page.click('#project-rename'); - await page.waitForFunction(() => location.pathname === '/lab'); - expect(await selected()).toBe('lab'); - - // Delete it: back to the first remaining project, dropdown updated. - queue.push((dialog) => dialog.accept()); - await page.click('#project-delete'); - await page.waitForFunction(() => location.pathname === '/main/demo.md'); - const options = await page.evaluate(() => - [...document.querySelectorAll('#project-select option')].map((option) => option.textContent), - ); - expect(options).not.toContain('lab'); - expect(options).not.toContain('research'); - } finally { - page.off('dialog', onDialog); - } + // Fill the in-app text dialog and confirm it. + const dialogSubmit = async (value: string) => { + await page.waitForSelector('#dialog:not([hidden]) #dialog-input:not([hidden])'); + await page.fill('#dialog-input', value); + await page.click('#dialog-confirm'); + await page.waitForSelector('#dialog', { state: 'hidden' }); + }; + + // Starting point: the specs project from the previous test. + await page.goto(`${server.url}/specs/plan.md`); + await page.waitForSelector('.cm-content'); + + // Cancelling the dialog changes nothing. + await page.click('#project-new'); + await page.waitForSelector('#dialog:not([hidden])'); + await page.click('#dialog-cancel'); + await page.waitForSelector('#dialog', { state: 'hidden' }); + expect(await selected()).toBe('specs'); + expect(await page.evaluate(() => location.pathname)).toBe('/specs/plan.md'); + + // A reserved project name is rejected and surfaced as an error toast. + await page.click('#project-new'); + await dialogSubmit('api'); + await page.waitForSelector('.toast-error'); + expect(await page.textContent('.toast-error')).toContain('reserved'); + expect(await selected()).toBe('specs'); + + // Create a project: URL becomes its page, sidebar is empty with an empty state. + await page.click('#project-new'); + await dialogSubmit('research'); + await page.waitForFunction(() => location.pathname === '/research'); + expect(await selected()).toBe('research'); + expect(await page.locator('#doc-list li').count()).toBe(0); + await page.waitForSelector('#empty-state:not([hidden])'); + + // Create a document — the .md extension is implied. + await page.click('#doc-new'); + await dialogSubmit('ideas'); + await page.waitForFunction(() => location.pathname === '/research/ideas.md'); + await page.locator('.cm-content').click(); + await page.keyboard.type('# Ideas from the UI'); + await waitForText('.cm-content', '# Ideas from the UI'); + + // Rename it (from the ⋯ menu): URL updates, content survives. + await docMenu('doc-rename'); + await dialogSubmit('brainstorm.md'); + await page.waitForFunction(() => location.pathname === '/research/brainstorm.md'); + await waitForText('.cm-content', '# Ideas from the UI'); + + // Move it to another project via the picker dialog. + await docMenu('doc-move'); + await page.click('.dialog-choice[data-value="main"]'); + await page.waitForFunction(() => location.pathname === '/main/brainstorm.md'); + await waitForText('.cm-content', '# Ideas from the UI'); + expect(await selected()).toBe('main'); + + // Delete it: the UI falls back to the project's first document. + await docMenu('doc-delete'); + await page.click('#dialog-confirm'); + await page.waitForFunction(() => location.pathname === '/main/demo.md'); + expect(await page.locator('li[data-path="main/brainstorm.md"]').count()).toBe(0); + + // Rename the (now empty) research project from the project ⋯ menu. + await page.selectOption('#project-select', 'research'); + await page.waitForFunction(() => location.pathname === '/research'); + await projectMenu('project-rename'); + await dialogSubmit('lab'); + await page.waitForFunction(() => location.pathname === '/lab'); + expect(await selected()).toBe('lab'); + + // Delete it: back to the first remaining project, dropdown updated. + await projectMenu('project-delete'); + await page.click('#dialog-confirm'); + await page.waitForFunction(() => location.pathname === '/main/demo.md'); + const options = await page.evaluate(() => + [...document.querySelectorAll('#project-select option')].map((option) => option.textContent), + ); + expect(options).not.toContain('lab'); + expect(options).not.toContain('research'); }, 60_000, ); @@ -638,7 +651,7 @@ test( await page.keyboard.type('\nVERSIONS_MARKER_ONE\n', { delay: 10 }); await waitForText('.cm-content', 'VERSIONS_MARKER_ONE'); - await page.click('#versions-open'); + await docMenu('versions-open'); await page.waitForSelector('#versions:not([hidden])'); expect(await page.textContent('#versions-title')).toBe('main/demo.md — versions'); await page.fill('#versions-label', 'checkpoint one'); @@ -656,10 +669,11 @@ test( await waitForText('.cm-content', 'VERSIONS_MARKER_TWO'); // Restore rolls the editor back to the checkpoint: ONE returns, TWO is gone. - await page.click('#versions-open'); + await docMenu('versions-open'); await page.waitForSelector('#versions:not([hidden])'); - page.once('dialog', (dialog) => dialog.accept()); await page.click('.version-restore'); + await page.waitForSelector('#dialog:not([hidden])'); + await page.click('#dialog-confirm'); await page.waitForFunction(() => { const text = document.querySelector('.cm-content')?.textContent ?? ''; return text.includes('VERSIONS_MARKER_ONE') && !text.includes('VERSIONS_MARKER_TWO'); @@ -732,7 +746,7 @@ test( await page.goto(`${server.url}/main/demo.md?name=Human`); await page.waitForSelector('.cm-content'); - await page.click('#project-mcp'); + await projectMenu('project-mcp'); await page.waitForSelector('#mcp-config:not([hidden]) .mcp-block pre'); const body = (await page.textContent('#mcp-config-body'))!; expect(body).toInclude('install.sh'); // the binary install one-liner