Files
kandev/docs/specs/ui/system-design/responsive-plan-formatting.md
T
2026-08-27 12:05:22 +00:00

200 lines
9.9 KiB
Markdown

---
status: draft
system: ui
requirements:
- REQ-UI-RESPONSIVE-PLAN-FORMATTING-001
---
# Responsive Plan Formatting System Design
## Purpose and boundaries
This design replaces the task Plan editor's selection-anchored formatting
surface on phone and touch-tablet layouts. Desktop keeps the current Tiptap
`BubbleMenu`. Mobile uses the same editor commands and state through a docked
presentation that does not share geometry with native text-selection chrome.
The task system continues to own plan data, revisions, autosave, and comments.
The plugin system's public `host.ui.RichTextEditor` props do not change. No
backend, API, persistence, or WebSocket boundary changes.
## Root cause and evidence
`PlanBubbleMenu` currently renders one Tiptap `BubbleMenu` for every non-empty
selection outside a code block and requests `placement: "top"` on every
viewport. Tiptap positions that DOM element from the selection rectangle.
Android and iOS independently position native selection actions around the
same rectangle, so Kandev cannot establish a reliable order between the two
surfaces through DOM placement, flipping, or `z-index`.
The existing `useVisualViewportOffset` hook and `MobileTerminalKeybar` already
demonstrate the required keyboard geometry. Their focused unit tests passed
during diagnosis, including top anchoring from `offsetTop + height` and
visual-viewport scroll updates.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-UI-RESPONSIVE-PLAN-FORMATTING-001` | [Responsive presentation](#responsive-presentation), [Editor state and commands](#editor-state-and-commands), [Viewport and scroll contract](#viewport-and-scroll-contract), [Interaction and accessibility](#interaction-and-accessibility), [Verification](#verification) |
## Responsive presentation
`TaskPlanPanel` selects presentation with `useResponsiveBreakpoint`:
- `isMobile` or `isTablet`: docked formatting strip.
- compact or full desktop: existing selection bubble.
The responsive choice is transient presentation state. It does not recreate
the editor, overwrite a preference, or change Markdown serialization. The
desktop and docked variants share the formatting controls and command handlers;
only their container, visibility rule, sizing, and position differ.
The mobile entry point is a non-whitespace text selection in the existing Plan
tab. The strip is a one-dimensional editor accessory while an eligible
selection is focused. A caret or whitespace-only selection leaves the strip
hidden to preserve editing space. This fits a frequent set of shallow actions
better than a drawer. Formatting actions come first, followed by link and the
selection-only Comment action. The inline link input temporarily replaces the
actions in the same docked surface and remains mounted while it owns focus.
The nearest shipped mobile exemplar is `MobileTerminalKeybar`. It contributes
visual-viewport positioning, keyboard focus retention, fixed-height horizontal
controls, and mobile task-navigation clearance. It does not define the Plan
toolbar's command set or touch sizing.
## Components and responsibilities
- `TaskPlanPanel` supplies the responsive presentation and the mobile task
navigation offset without changing plan state or autosave behavior.
- `TipTapPlanEditor` forwards the selected presentation to the formatting
surface and reserves editor scroll space while the docked strip is active.
- `PlanBubbleMenu` retains the current desktop BubbleMenu behavior and owns the
docked variant, shared controls, link flow, and comment selection handoff.
- `useVisualViewportOffset` continues to publish keyboard-open state and the
visual viewport's bottom edge. A pure shared positioning helper accepts bar
height and the host's closed-keyboard bottom offset.
- `MobileTerminalKeybar` adopts the shared positioning helper without changing
its rendered geometry or shell-input behavior.
The mobile task layout remains the single source for its bottom-navigation
height. It passes that value down as an internal presentation input rather than
introducing a second literal or a persisted setting.
## Editor state and commands
The formatting surface subscribes to Tiptap transactions for the current
selection, code-block context, link state, and active inline marks. It also
subscribes to editor focus and blur events because Tiptap's React editor-state
selector updates on transactions, not focus changes alone.
The docked strip is visible when the Plan editor is focused and its current
selection contains non-whitespace text, or while its link input owns focus. It
is hidden for a caret, a whitespace-only selection, and a code block. Bold,
italic, underline, strikethrough, inline code, highlight, and link reuse the
current editor command chains. Comment remains available only when the Plan
panel supplied its comment callback and the current selection contains
non-whitespace text.
The desktop BubbleMenu retains its non-empty-selection visibility condition.
It consumes the same reactive active-mark snapshot as the docked strip so
pressed styling follows selection and command transactions in both variants.
## Render and transaction stability
The desktop BubbleMenu receives stable option and callback identities when
their values do not change. Tiptap dispatches a metadata transaction when its
menu options change. A new options object on each render can create a feedback
loop between that transaction and the editor snapshot subscription.
The editor snapshot subscription compares each derived snapshot with the
current snapshot. It publishes state only when focus, selection, code-block
context, or active marks change. Metadata-only transactions do not cause a
render. Selection and formatting transactions still publish the changed
snapshot.
These two controls keep the desktop selection bubble reactive without making
Tiptap option updates part of the React render cycle. The mobile presentation
uses the same deduplicated editor snapshot.
## Viewport and scroll contract
The docked strip has a stable measured height. Its position resolves as follows:
1. When the keyboard is open, use fixed positioning with `top` equal to the
visual viewport's bottom edge minus the strip height. This follows the
existing iOS-safe terminal keybar behavior.
2. When the keyboard is closed, use fixed bottom positioning with the mobile
task-navigation offset plus `env(safe-area-inset-bottom, 0px)`.
3. Without Visual Viewport API support, treat the keyboard as closed and use
the safe bottom fallback.
The Plan editor remains the only vertical scroll owner. While the strip is
visible, the editable region reserves at least the strip height at its bottom.
The strip itself can scroll horizontally and uses overscroll containment. It
must not widen the document or create a second vertical scroller.
## Interaction and accessibility
Docked icon buttons use semantic `button` elements with localized accessible
names and `aria-pressed` for toggled marks. Each button has a minimum 44-pixel
touch dimension even though the compact visual action surface is 32 pixels and
the dock is 48 pixels tall.
Formatting buttons prevent default on both `pointerdown` and `mousedown` so a
touch does not move focus or collapse the selection before the command runs.
The click handler applies one command and explicitly returns focus to the
editor. The link button is the exception only after it intentionally focuses
the inline URL input; Apply and Escape return to the editor consistently.
Kandev does not set `user-select: none`, disable the WebKit touch callout, or
intercept native clipboard actions on the editable content.
## Failure and recovery
- A missing or destroyed editor keeps both presentations unmounted.
- A focus transition outside the editor and link input hides the docked strip;
refocusing restores it from current editor state.
- A caret or whitespace-only selection hides the dock without affecting the
editor's native keyboard and selection behavior.
- If the visual viewport changes repeatedly while the keyboard animates, the
latest hook snapshot determines position; no timer, polling loop, or global
store is introduced.
- A browser that does not expose Visual Viewport API receives the closed-
keyboard fallback. Native selection actions remain available in all cases.
## Persistence and security
All responsive, focus, selection, and toolbar state is transient browser state.
No data migration or new trusted input exists. Link values continue through
the existing Tiptap link command and current rendering protections.
## Verification
Component tests cover the responsive branch, reactive mark/selection state,
selection-driven visibility, link mode, compact sizing, selection-preserving
pointer behavior, and the shared visual-viewport positioning helper. Existing
terminal keybar tests guard the extracted geometry against regression.
A `mobile-chrome` Playwright scenario opens a seeded task plan, focuses the real
editor, selects text, taps Bold, and verifies the serialized/rendered result.
It also drives visual-viewport resize and scroll events using the established
terminal-keybar test pattern, then asserts keyboard-edge tracking, task-nav
clearance, 44-pixel controls, internal horizontal overflow, and no document-
level horizontal overflow.
Desktop browser emulation cannot render Android or iOS native selection chrome.
Final device acceptance therefore checks Android Chrome and iOS Safari to
confirm that native Cut, Copy, and Paste remain usable while Kandev's strip
stays at the keyboard edge. Automated tests prove the Kandev geometry,
selection-driven visibility, compact sizing, and formatting outcome rather than
claiming to inspect operating-system UI.
No production telemetry is added; deterministic component and browser evidence
is sufficient for this local presentation correction.
## Related decisions
None. This corrects an existing responsive UI surface and reuses an established
visual-viewport pattern without creating a new architecture boundary.