"Brief Modal — issue deep-view with Yjs description editor + threaded comments + LWW meta sidebar + presence cursors"
§1 — Description (BCP-14 normative)
The Brief Modal MUST be a unified deep-view for one issue with collaborative description + comments + meta sidebar. The contract:
- MUST open via:
- Click on issue card in any view (Kanban / Timeline / Gantt).
- Enter key on focused card.
- Direct URL
/proj/issues/:id/brief. Opening updates URL (history.pushState) so back-button + share-link work.
- MUST render responsively:
- Mobile (< 1024px): full-screen overlay; sidebar collapses to expandable section.
- Desktop (≥ 1024px): right-side panel 480px wide; sidebar always-visible.
- MUST bind description to Y.Text via TASK-PROJ-003 YjsProvider and TipTap +
@tiptap/extension-collaboration. Concurrent edits converge per Yjs. - MUST render comments as Y.Array; each comment is
Y.Map { id, author_id, body: Y.Text, created_at }. New comment composer adds element to array; edit binds to body Y.Text. - MUST render meta sidebar with LWW scalars (TASK-PROJ-003 §1 #6):
- Status (uses TASK-PROJ-014 StatusPicker).
- Assignee dropdown.
- Priority radio.
- Estimate number input.
- Labels multi-select.
- Dates (starts_at + ends_at). Each field PATCHes via LWW endpoint; stale-write → toast + revert.
- MUST render presence cursors:
- Other users editing this modal → their cursor position in description shown as labeled flag (name + color).
- Cursor flag throttled at 30 Hz per TASK-PROJ-003 awareness.
- Cursor expires 30s after last awareness heartbeat.
- MUST provide a history drawer toggle (button +
Hshortcut):
- When open, side panel shows TASK-PROJ-008 history_event timeline.
- Chain_anchor verification status per row (green check or red warn).
- Click on history row scrolls description to that mutation's snapshot.
- MUST support kbd shortcuts:
- Esc closes modal (no confirmation; CRDT auto-saves).
- T puts cursor in title field (inline-edit).
- C focuses new-comment composer.
- H toggles history drawer.
- Cmd+S explicit save (no-op visual feedback; "auto-saved" indicator).
- MUST emit memory audit
proj.brief_modal_openedper open with{issue_id, by_subject_id, opened_from, trace_id}where opened_from ∈ kanban | timeline | gantt | url | search. - MUST RLS-enforce (issue + comments + history).
- MUST pass axe-core (focus-trap inside modal; restore focus on close; aria-modal=true).
- MUST emit OTel:
proj_brief_modal_opens_total{opened_from}(counter).proj_brief_modal_render_p95_ms(histogram).proj_brief_modal_session_seconds(histogram — engagement signal).
- MUST support comment threading: each comment can be a reply to another via
reply_to_comment_id; threading rendered with visual indentation (max depth 5). - MUST support comment mentions:
@usernamein comment body resolves to user; sends in-app notification to mentioned user via CUO triage. - MUST support attachments on comments: file upload via task-FILES (max 25MB per file, 5 files per comment); previewable images/PDFs inline.
- MUST support reactions on comments: emoji picker; each comment shows reaction tallies; clicking re-toggles user's reaction.
- MUST support
@lumiinvocation in comments: routes to TASK-CHAT-008 sibling handler scoped to issue context (description + recent comments). - MUST support "link" actions in sidebar: quick-add issue dependencies (TASK-PROJ-016) + memory-links (TASK-PROJ-009) without leaving modal.
- MUST support draft comment auto-save: composer text persists per-issue per-user in
localStorage; on next modal open, restored. - MUST support keyboard navigation through comments: J/K moves comment focus; Reply opens reply composer threaded under that comment.
- MUST show "X is typing..." indicator below comment composer when another user has the composer open; throttled per Yjs awareness.
- MUST include a "follow / unfollow" toggle: followers get CUO notifications on any update to this issue (comments, status, assignee changes).
- MUST support markdown shortcuts in the description editor: TipTap configures
**bold**/_italic_/# headingetc. matching standard markdown syntax.
§2 — Why this design (rationale for humans)
Why one modal for everything (DEC-380)? Three views (Kanban/Timeline/Gantt) all need deep-edit; unifying = one place for edits = no UX drift. URL-deep-linkable = shareable.
Why responsive split (DEC-382)? Mobile users need full screen for editing; desktop users want issue visible in board context while editing. 1024px is the standard tablet threshold.
Why TipTap (DEC-381)? Industry-standard React rich-text editor; first-class Yjs integration via @tiptap/extension-collaboration. Alternatives (Slate, Lexical) have less mature Yjs binding.
Why no unsaved state (§1 #8)? CRDT auto-saves every keystroke; "save" is mental. Esc-to-close without confirmation = trust the system. Cmd+S is muscle-memory affordance returning "auto-saved" toast.
Why presence cursors (§1 #6)? Two users editing same paragraph collide → CRDT resolves correctly but UX is confusing without seeing the other person. Labeled cursors = "Bob is here" signal.
Why history drawer toggle (§1 #7)? History is per-issue context but bulky. Default hidden; toggle reveals. Power users keep it open during reviews.
Why audit modal opens (§1 #9)? Per-issue engagement metrics inform UX. "How often do users open issues from Kanban vs URL?" informs onboarding flows.
Why focus-trap (§1 #11)? WCAG requires modals keep keyboard focus inside; releases on close. Standard accessibility pattern.
Why threading (§1 #13)? Long comment threads need reply context; flat list loses conversation structure.
Why mentions + notify (§1 #14)? Mention is the standard "tag someone for attention" pattern; notification closes the loop.
Why comment attachments (§1 #15)? Real workflows attach screenshots, logs, designs. Without inline upload = workflow friction.
Why comment reactions (§1 #16)? Lightweight signal ("agree", "this") without writing a reply; reduces comment noise.
Why @lumi in comments (§1 #17)? LLM-assisted clarification inline; doesn't require leaving the modal.
Why link actions in sidebar (§1 #18)? Adding dependencies/memory-links is workflow-adjacent; in-modal action eliminates context switch.
Why draft auto-save (§1 #19)? Operator drafting long comment + modal accidentally closes = lost text. localStorage = survives session.
Why kbd comment nav (§1 #20)? Power users review many comments; kbd parity for review workflow.
Why typing indicator (§1 #21)? Two users typing replies simultaneously waste effort; awareness signal prevents duplicate work.
Why follow/unfollow (§1 #22)? Operators want updates on issues they care about; default-following all might over-notify.
Why markdown shortcuts (§1 #23)? Markdown is the universal text-formatting language; operators expect it.
§3 — API contract
// web/proj-client/src/views/BriefModal/Modal.tsx
export function BriefModal({ issueId, openedFrom }: { issueId: string; openedFrom: OpenedFrom }) {
const yjs = useYjsProvider(issueId);
const [historyOpen, setHistoryOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 1023px)');
useEffect(() => {
emitMemory('proj.brief_modal_opened', { issue_id: issueId, opened_from: openedFrom });
history.pushState({}, '', `/proj/issues/${issueId}/brief`);
return () => {
// Restore prior URL on close
};
}, []);
useKeyboardShortcuts({
Escape: closeModal,
T: () => focusTitle(),
C: () => focusCommentComposer(),
H: () => setHistoryOpen(o => !o),
'Mod+S': () => toast('Auto-saved', 'success'),
});
return (
<Dialog open={true} onOpenChange={closeModal}
className={isMobile ? 'fullscreen' : 'side-panel'}
aria-modal="true" aria-labelledby="issue-title">
<FocusTrap>
<div className="brief-modal">
<Header issueId={issueId} />
<Description yjs={yjs} />
<PresenceCursors yjs={yjs} />
<CommentThread yjs={yjs} />
<MetaSidebar issueId={issueId} />
{historyOpen && <HistoryDrawer issueId={issueId} />}
</div>
</FocusTrap>
</Dialog>
);
}
// web/proj-client/src/views/BriefModal/Description.tsx
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
export function Description({ yjs }: { yjs: YjsProvider }) {
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }), // Yjs handles undo
Collaboration.configure({ document: yjs.doc, field: 'description' }),
CollaborationCursor.configure({
provider: yjs.wsProvider,
user: { name: currentUser.name, color: userColor(currentUser.id) },
}),
],
});
return <EditorContent editor={editor} className="description-editor" />;
}
// web/proj-client/src/views/BriefModal/MetaSidebar.tsx
export function MetaSidebar({ issueId }: { issueId: string }) {
const issue = useIssue(issueId);
return (
<aside className="meta-sidebar">
<StatusPicker issueId={issueId} current={issue.status} onChange={patchStatus} />
<AssigneePicker issueId={issueId} current={issue.assignee_id} onChange={patchAssignee} />
<PriorityPicker issueId={issueId} current={issue.priority} onChange={patchPriority} />
<EstimateInput issueId={issueId} current={issue.estimate} onChange={patchEstimate} />
<LabelMultiSelect issueId={issueId} current={issue.labels} onChange={patchLabels} />
<DateRange issueId={issueId} starts={issue.starts_at} ends={issue.ends_at} onChange={patchDates} />
</aside>
);
async function patchStatus(to: IssueStatus, reason?: string) {
const res = await postTransition(issueId, to, reason);
if (res.error === 'stale_write') { toast('Refreshed; please retry'); }
}
// ... similar patch functions for other fields, each calls writeScalarLWW
}
// web/proj-client/src/views/BriefModal/HistoryDrawer.tsx
export function HistoryDrawer({ issueId }: { issueId: string }) {
const history = useIssueHistory(issueId);
return (
<div className="history-drawer" role="region" aria-label="Issue history">
<h3>History</h3>
<ol>
{history.map(h => (
<li key={h.event.id}>
<span>{h.event.mutation_kind}</span>
<span>{h.event.field}</span>
<span>by {h.event.by_subject_id}</span>
{h.chain_verified
? <span aria-label="chain verified" title="chain verified">✓</span>
: <span aria-label="chain mismatch — sev-1 alert" title="chain mismatch" className="warn">⚠</span>}
</li>
))}
</ol>
</div>
);
}
§4 — Acceptance criteria
- Open from Kanban Enter — focused card + Enter → modal opens; URL updates.
- Open from URL deep-link — visit
/proj/issues/iss-X/brief→ modal opens. - Esc closes — modal closes; URL restored.
- Description CRDT — two users typing → both converge via Yjs.
- Comments Y.Array — add comment → appears in both users' modals real-time.
- Meta LWW: status — change status → POST transition; new value persists.
- Meta LWW: stale write — concurrent assignee change → second user gets stale_write; toast.
- Presence cursor visible — second user opens modal → their cursor appears with name + color.
- Presence cursor expires — second user closes browser → cursor gone within 30s.
- History drawer toggle (H) — H opens; H closes.
- Chain anchor verify icon — happy history → green check; tampered → red warn.
- Mobile full-screen — viewport < 1024px → full-screen layout.
- Desktop side-panel — viewport ≥ 1024px → 480px right panel.
- Kbd T focuses title — modal open + T → title inline-edit focused.
- Kbd C focuses comment composer — C → focus on new comment input.
- Cmd+S shows auto-saved toast — no-op but feedback.
- Focus trap — Tab cycles within modal; doesn't escape to background.
- Focus restore on close — modal close → focus returns to opening element.
- memory audit modal_opened — per open → row with opened_from.
- OTel modal_opens_total counter — per open → counter increments.
- axe-core passes — aria-modal + focus-trap + labels correct.
- RLS isolates — tenant A's issue invisible to tenant B's modal request → 404.
- Comment thread depth ≤ 5 — replies indent up to depth 5; beyond → flat with marker (AC for §1 #13).
- Mention notifies user — comment with
@alice→ CUO notification queued for alice (AC for §1 #14). - Attachment uploads — drop file → task-FILES upload; preview inline (AC for §1 #15).
- Reaction toggle — click emoji → toggles user's reaction; tally updates (AC for §1 #16).
- @lumi in comment routes to handler — comment with @lumi → reply appears as Lumi-authored comment (AC for §1 #17).
- Sidebar quick-link actions — click "Add Dep" in sidebar → opens TASK-PROJ-016 dialog inline (AC for §1 #18).
- Draft auto-save survives close — draft text in composer; close modal; reopen → text restored (AC for §1 #19).
- Kbd J/K navigates comments — comment focused → J moves to next; K to prior (AC for §1 #20).
- Typing indicator visible — second user types in composer → first user sees "X is typing..." (AC for §1 #21).
- Follow toggle adds to followers — toggle → CUO notifications start for that user (AC for §1 #22).
- Markdown
**bold**works — type**bold**in editor → renders bold (AC for §1 #23).
§5 — Verification
test('Esc closes modal', async () => {
const { user } = render(<BriefModal issueId="iss-1" openedFrom="kanban" />);
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
test('description converges with Yjs', async () => {
const { user: u1 } = render(<BriefModal issueId="iss-1" openedFrom="url" />);
const editor = screen.getByRole('textbox');
await u1.type(editor, 'Hello from user 1');
// simulate user 2 in a parallel doc
const u2Doc = simulateYjsPeer('iss-1');
u2Doc.getText('description').insert(0, 'User 2 was here. ');
await waitFor(() => expect(editor).toHaveTextContent(/User 2 was here.*Hello from user 1/));
});
test('LWW stale_write shows toast', async () => {
const { user } = render(<BriefModal issueId="iss-1" openedFrom="kanban" />);
mockLww.fail409('status');
await user.click(screen.getByText('In Progress'));
expect(screen.getByRole('alert')).toHaveTextContent(/refreshed/i);
});
test('history drawer toggle with H', async () => {
const { user } = render(<BriefModal issueId="iss-1" openedFrom="url" />);
await user.keyboard('h');
expect(screen.getByRole('region', { name: 'Issue history' })).toBeInTheDocument();
await user.keyboard('h');
expect(screen.queryByRole('region', { name: 'Issue history' })).toBeNull();
});
test('chain anchor mismatch shows warn icon', async () => {
mockHistory.tamperRow(2);
const { user } = render(<BriefModal issueId="iss-1" openedFrom="url" />);
await user.keyboard('h');
const warns = screen.getAllByLabelText(/chain mismatch/);
expect(warns).toHaveLength(1);
});
test('focus trap inside modal', async () => {
const { user } = render(<BriefModal issueId="iss-1" openedFrom="url" />);
// Tab repeatedly; focus should never leave dialog
for (let i = 0; i < 20; i++) await user.tab();
expect(screen.getByRole('dialog')).toContainElement(document.activeElement!);
});
§6 — Implementation skeleton
(Sketches above.)
§7 — Dependencies
- TASK-PROJ-002 — WS.
- TASK-PROJ-003 — Y.Doc + LWW + awareness.
- TASK-PROJ-004 — status transitions (StatusPicker).
- TASK-PROJ-008 — history_event source.
- TASK-PROJ-014 — opener (Kanban).
- TASK-PROJ-018 — design tokens.
§8 — Example payloads
{
"kind": "proj.brief_modal_opened",
"payload": {
"issue_id": "iss-...",
"by_subject_id": "7e57c0de-...",
"opened_from": "kanban",
"trace_id": "0af..."
}
}
§9 — Open questions
All resolved. Deferred:
- Multiple modals side-by-side (split view) — slice 4+.
- Modal-within-modal for linked issue navigation — slice 4+.
- AI assistant inside modal ("explain this issue") — slice 4+.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| YjsProvider connect fails | error state | Read-only banner; description shows last snapshot | Reconnect drains |
| Issue deleted while modal open | 404 on poll | Toast + auto-close | None |
| History fetch fails | error state | Banner; drawer empty | Retry |
| LWW stale_write | 409 | Toast; revert local field | User refreshes |
| Concurrent edit causes CRDT churn | Yjs handles | None | None |
| Comment composer disconnected | offline buffer | Comment queues; sent on reconnect | None |
| Presence flood | 30Hz throttle | None | None |
| Mobile viewport switch | useMediaQuery handles | Layout reflows | None |
| Focus escape (axe) | a11y test catches | CI blocked | Fix focus-trap |
| Chain anchor mismatch | red warn icon | Sev-1 alarm via TASK-PROJ-008 metric | Operator investigates |
| Modal opened with invalid issueId | 404 | Toast + close | None |
| Browser back navigates away | URL state restored | None | None |
| Modal stuck (component crash) | error boundary | Recover | None |
| TipTap version mismatch | initial render fail | Sev-1 | Pin version |
| Comment with embedded scripts | TipTap sanitisation | Safe | None |
| Comment thread depth > 5 | flat fallback with marker | None | None |
| Mention to non-existent user | passed verbatim; no notification | None | None |
| Attachment > 25MB | rejected upfront | toast | Caller resizes |
| Reaction spam (1000s of clicks) | debounce + rate-limit | None | None |
| @lumi in comment with tenant lumi disabled | falls back to TASK-CHAT-008 behavior | None | None |
| Sidebar quick-link opens stale dependencies | refetch on open | None | None |
| Draft auto-save with multi-tab | localStorage shared; last-write-wins | None | None |
| Typing indicator stuck (user closed browser) | 30s expiry | None | None |
| Follow toggle for already-followed | no-op | None | None |
| Markdown shortcut conflict (operator types ** literal) | escape support | None | None |
| Threaded reply notification spam | dedup per (user, comment) | None | None |
| Comment with mention + attachment + reaction | all work concurrently | None | None |
| LocalStorage full | warn; oldest drafts evicted | None | Operator clears |
| Mobile keyboard takes half screen | viewport adjust | None | None |
| Modal close mid-mention-resolution | notification still sent | None | None |
§11 — Implementation notes
- TipTap's StarterKit's history is disabled because Yjs provides its own undo via
Y.UndoManager. - CollaborationCursor uses Yjs awareness state; user color derived from subject_id hash (consistent across sessions).
- History drawer uses
useIssueHistoryhook with SWR caching; refetch on focus. - Focus-trap library:
@radix-ui/react-dialogprovides built-in focus management. - The
opened_fromvalue is set by the caller; URL opens default to "url". - The 480px desktop side-panel width is a design token (TASK-PROJ-018).
- Modal session histogram captures engagement: how long users spend per issue.
- Comments are appended to Y.Array; deletion via Y.Array.delete() preserves CRDT history.
- Threading uses
reply_to_comment_idfield; rendering recursively indents up to depth 5; deeper threads flatten with "deeply nested" marker. - Mention regex matches
@[a-zA-Z0-9_]+; resolves against users table for tenant. - Attachment upload uses task-FILES presigned URL flow; preview component handles common MIME types.
- Reactions use Y.Map (emoji → Set of user_ids); CRDT handles concurrent toggles.
- @lumi in comments routes to chat-lumi service with
context="issue-comment"; reply appears as a comment authored by Lumi system user. - Sidebar quick-link actions open inline dialogs (radix UI Dialog) without leaving modal.
- Draft auto-save uses
localStorage[draft_comment_${issueId}_${userId}]; debounced at 500ms. - Kbd J/K navigates comments in DOM order; Reply opens reply composer under focused comment.
- Typing indicator uses Yjs awareness
composing: truefield; throttled and TTL'd. - Follow toggle creates row in
cyberos_proj_issue_followerstable; CUO triages notifications based on this list. - Markdown shortcuts use TipTap's
Markdownextension; configured for standard CommonMark subset. - Reaction emoji picker uses
@emoji-mart/react(lazy-loaded ~80KB). - Mention auto-complete uses
@tiptap/extension-mention; suggests as user types. - Threaded reply UI collapses to "N replies" link when thread > 10 comments; click expands.
- Comment edit window: 15 minutes from create; after that, edit produces a "edited at" indicator + history row.
- We considered comment soft-delete vs hard-delete; chose soft (mark deleted, body redacted) for audit trail.
- Comment threading depth ≤ 5 calibrated against UX studies; deeper = unreadable.
- Mention notifications respect notify_props (TASK-CHAT-011-style); user can disable mentions per-engagement.
- The "@lumi in comment" feature is opt-in per engagement (per-tenant Lumi settings inherited).
- Sidebar quick-links emit memory audit per action (link added/removed).
- Draft auto-save is cleared on successful comment submit OR explicit "discard draft" button.
- Reactions don't notify; they're lightweight feedback. Operators wanting notify use mentions.
- Markdown shortcuts work in description editor AND comment composer; consistent UX.
- The follow/unfollow toggle defaults to follow for issue authors + assignees; explicit follow for others.
- Comment with mention + attachment + reaction emits multiple audit rows; correlated via comment_id.
- Modal session tracking starts on first render, ends on close; histogram captures real engagement.
End of TASK-PROJ-017.
As built (2026-07-02)
Client code lives under apps/web/src (there is no web/proj-client/).