Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project> / <title>` (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
Expand Down
152 changes: 152 additions & 0 deletions src/client/dialogs.ts
Original file line number Diff line number Diff line change
@@ -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);
}
76 changes: 58 additions & 18 deletions src/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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">
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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"
Expand Down
Loading
Loading