Task — engineering-spec@1

"Brief Modal — issue deep-view with Yjs description editor + threaded comments + LWW meta sidebar + presence cursors"

doneTASK-PROJ-017
module proj · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-PROJ-003 · blocks none

§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:

  1. MUST open via:
  1. MUST render responsively:
  1. MUST bind description to Y.Text via TASK-PROJ-003 YjsProvider and TipTap + @tiptap/extension-collaboration. Concurrent edits converge per Yjs.
  2. 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.
  3. MUST render meta sidebar with LWW scalars (TASK-PROJ-003 §1 #6):
  1. MUST render presence cursors:
  1. MUST provide a history drawer toggle (button + H shortcut):
  1. MUST support kbd shortcuts:
  1. MUST emit memory audit proj.brief_modal_opened per open with {issue_id, by_subject_id, opened_from, trace_id} where opened_from ∈ kanban | timeline | gantt | url | search.
  2. MUST RLS-enforce (issue + comments + history).
  3. MUST pass axe-core (focus-trap inside modal; restore focus on close; aria-modal=true).
  4. MUST emit OTel:
  1. 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).
  2. MUST support comment mentions: @username in comment body resolves to user; sends in-app notification to mentioned user via CUO triage.
  3. MUST support attachments on comments: file upload via task-FILES (max 25MB per file, 5 files per comment); previewable images/PDFs inline.
  4. MUST support reactions on comments: emoji picker; each comment shows reaction tallies; clicking re-toggles user's reaction.
  5. MUST support @lumi invocation in comments: routes to TASK-CHAT-008 sibling handler scoped to issue context (description + recent comments).
  6. MUST support "link" actions in sidebar: quick-add issue dependencies (TASK-PROJ-016) + memory-links (TASK-PROJ-009) without leaving modal.
  7. MUST support draft comment auto-save: composer text persists per-issue per-user in localStorage; on next modal open, restored.
  8. MUST support keyboard navigation through comments: J/K moves comment focus; Reply opens reply composer threaded under that comment.
  9. MUST show "X is typing..." indicator below comment composer when another user has the composer open; throttled per Yjs awareness.
  10. MUST include a "follow / unfollow" toggle: followers get CUO notifications on any update to this issue (comments, status, assignee changes).
  11. MUST support markdown shortcuts in the description editor: TipTap configures **bold** / _italic_ / # heading etc. 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

  1. Open from Kanban Enter — focused card + Enter → modal opens; URL updates.
  2. Open from URL deep-link — visit /proj/issues/iss-X/brief → modal opens.
  3. Esc closes — modal closes; URL restored.
  4. Description CRDT — two users typing → both converge via Yjs.
  5. Comments Y.Array — add comment → appears in both users' modals real-time.
  6. Meta LWW: status — change status → POST transition; new value persists.
  7. Meta LWW: stale write — concurrent assignee change → second user gets stale_write; toast.
  8. Presence cursor visible — second user opens modal → their cursor appears with name + color.
  9. Presence cursor expires — second user closes browser → cursor gone within 30s.
  10. History drawer toggle (H) — H opens; H closes.
  11. Chain anchor verify icon — happy history → green check; tampered → red warn.
  12. Mobile full-screen — viewport < 1024px → full-screen layout.
  13. Desktop side-panel — viewport ≥ 1024px → 480px right panel.
  14. Kbd T focuses title — modal open + T → title inline-edit focused.
  15. Kbd C focuses comment composer — C → focus on new comment input.
  16. Cmd+S shows auto-saved toast — no-op but feedback.
  17. Focus trap — Tab cycles within modal; doesn't escape to background.
  18. Focus restore on close — modal close → focus returns to opening element.
  19. memory audit modal_opened — per open → row with opened_from.
  20. OTel modal_opens_total counter — per open → counter increments.
  21. axe-core passes — aria-modal + focus-trap + labels correct.
  22. RLS isolates — tenant A's issue invisible to tenant B's modal request → 404.
  23. Comment thread depth ≤ 5 — replies indent up to depth 5; beyond → flat with marker (AC for §1 #13).
  24. Mention notifies user — comment with @alice → CUO notification queued for alice (AC for §1 #14).
  25. Attachment uploads — drop file → task-FILES upload; preview inline (AC for §1 #15).
  26. Reaction toggle — click emoji → toggles user's reaction; tally updates (AC for §1 #16).
  27. @lumi in comment routes to handler — comment with @lumi → reply appears as Lumi-authored comment (AC for §1 #17).
  28. Sidebar quick-link actions — click "Add Dep" in sidebar → opens TASK-PROJ-016 dialog inline (AC for §1 #18).
  29. Draft auto-save survives close — draft text in composer; close modal; reopen → text restored (AC for §1 #19).
  30. Kbd J/K navigates comments — comment focused → J moves to next; K to prior (AC for §1 #20).
  31. Typing indicator visible — second user types in composer → first user sees "X is typing..." (AC for §1 #21).
  32. Follow toggle adds to followers — toggle → CUO notifications start for that user (AC for §1 #22).
  33. 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


§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:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
YjsProvider connect failserror stateRead-only banner; description shows last snapshotReconnect drains
Issue deleted while modal open404 on pollToast + auto-closeNone
History fetch failserror stateBanner; drawer emptyRetry
LWW stale_write409Toast; revert local fieldUser refreshes
Concurrent edit causes CRDT churnYjs handlesNoneNone
Comment composer disconnectedoffline bufferComment queues; sent on reconnectNone
Presence flood30Hz throttleNoneNone
Mobile viewport switchuseMediaQuery handlesLayout reflowsNone
Focus escape (axe)a11y test catchesCI blockedFix focus-trap
Chain anchor mismatchred warn iconSev-1 alarm via TASK-PROJ-008 metricOperator investigates
Modal opened with invalid issueId404Toast + closeNone
Browser back navigates awayURL state restoredNoneNone
Modal stuck (component crash)error boundaryRecoverNone
TipTap version mismatchinitial render failSev-1Pin version
Comment with embedded scriptsTipTap sanitisationSafeNone
Comment thread depth > 5flat fallback with markerNoneNone
Mention to non-existent userpassed verbatim; no notificationNoneNone
Attachment > 25MBrejected upfronttoastCaller resizes
Reaction spam (1000s of clicks)debounce + rate-limitNoneNone
@lumi in comment with tenant lumi disabledfalls back to TASK-CHAT-008 behaviorNoneNone
Sidebar quick-link opens stale dependenciesrefetch on openNoneNone
Draft auto-save with multi-tablocalStorage shared; last-write-winsNoneNone
Typing indicator stuck (user closed browser)30s expiryNoneNone
Follow toggle for already-followedno-opNoneNone
Markdown shortcut conflict (operator types ** literal)escape supportNoneNone
Threaded reply notification spamdedup per (user, comment)NoneNone
Comment with mention + attachment + reactionall work concurrentlyNoneNone
LocalStorage fullwarn; oldest drafts evictedNoneOperator clears
Mobile keyboard takes half screenviewport adjustNoneNone
Modal close mid-mention-resolutionnotification still sentNoneNone

§11 — Implementation notes


End of TASK-PROJ-017.

As built (2026-07-02)

Client code lives under apps/web/src (there is no web/proj-client/).