Contribute rewrite Phase 6: narrow-viewport right-panel drawer

Below 1280px the right-panel (chat + change-card panel) collapses into
an off-canvas drawer toggled from a Chat button in the editor toolbar.
Above 1280px the inline three-column layout is unchanged. Replaces the
Phase 2 narrow-viewport-banner stopgap.

- drawerOpen useState in RFCView; per-session, default closed; tied to
  a .rfc-body.drawer-open class plus .right-panel.drawer-open and the
  backdrop's data-open attribute.
- ChatDrawerToggleButton in each editor-toolbar branch (Contribute,
  Discuss-with-accepted-changes, and a narrow-only fallback toolbar
  for the default state). Badge mirrors change-panel-header: pending
  AI changes + the buffered manual card. Hidden above 1280px via CSS.
- .right-panel becomes position: fixed; top: 48px; right: 0; width:
  min(420px, 92vw); transform: translateX(100%) by default; slides in
  with .drawer-open. Backdrop sibling with opacity transition handles
  click-to-close.
- Escape closes the drawer when open (useEffect listener gated on
  drawerOpen, so it's a no-op when closed).
- Toolbar gets padding-right shifted to drawer-width when the drawer
  is open at narrow widths so the toggle stays clickable to close
  (otherwise it'd sit under the open drawer).
- @media (min-width: 1280px) restores .right-panel to inline flex
  (360px, position: static), hides the backdrop, hides the toggle
  button, and hides the narrow-only toolbar fallback.
- Removes the .narrow-viewport-banner JSX + its CSS block + its
  @media display:none rule.

SPEC.md §8.1 gains a paragraph noting the narrow-viewport drawer
collapse — explicit acknowledgment that the three-column shape adapts
below ~1280px without re-architecting around responsive primitives.

Verified in Vite preview sandbox at 1024px (drawer offscreen,
backdrop opacity 0, toggle visible) and 1440px (inline three-column,
drawer fixed-positioning reset, toggle hidden, backdrop display:none).
Backend was down this session (proxy 502 on /api/me) so the cumulative
Phase 2–5 end-to-end verification gap is still open; sandbox proof
only, as called out in the phase prompt.

Backend integration suite: 125 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 12:02:52 -07:00
parent 3212dc19ee
commit 49e91f8829
3 changed files with 151 additions and 22 deletions
+69 -11
View File
@@ -555,16 +555,6 @@
border-radius: 2px; padding: 1px 2px; cursor: pointer;
}
/* Narrow-viewport banner — Phase 2 punts the chat-drawer collapse to
Phase 6, so the split layout asks for ≥1280px. */
.narrow-viewport-banner {
padding: 8px 16px;
background: #fef3c7; border-bottom: 1px solid #fcd34d;
color: #78350f; font-size: 12px;
}
@media (min-width: 1280px) {
.narrow-viewport-banner { display: none; }
}
.cm-source-editor .cm-editor {
flex: 1; min-height: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
@@ -688,11 +678,79 @@
/* ── Right panel (chat + change panel) ───────────────────────────────── */
/* Phase 6 — narrow viewport (<1280px) defaults: the right panel is an
off-canvas drawer sliding in from the right. The header (.app-header)
is 48px tall and stays visible above the drawer so branch/breadcrumb
navigation remains accessible. The @media (min-width: 1280px) block
below restores the inline three-column layout. */
.right-panel {
width: 360px; flex-shrink: 0;
position: fixed;
top: 48px; right: 0; bottom: 0;
width: min(420px, 92vw);
border-left: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden; background: #fff;
transform: translateX(100%);
transition: transform 150ms ease;
z-index: 30;
box-shadow: -8px 0 24px rgba(0,0,0,0.08);
}
.right-panel.drawer-open { transform: translateX(0); }
.right-panel-backdrop {
position: fixed; inset: 48px 0 0 0;
background: rgba(0,0,0,0.2);
z-index: 29;
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
.right-panel-backdrop[data-open="true"] {
opacity: 1;
pointer-events: auto;
}
/* Chat drawer toggle button — sits in the editor toolbar at narrow widths
and gets hidden above 1280px (where the right panel is always inline). */
.btn-chat-drawer-toggle {
margin-left: auto;
display: inline-flex; align-items: center; gap: 6px;
background: #fff; color: #1a1a1a;
border: 1px solid #d4d4d4; border-radius: 5px;
padding: 4px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-chat-drawer-toggle.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.btn-chat-drawer-toggle .badge {
font-size: 10px; padding: 1px 6px;
background: #b45309; color: #fff; border-radius: 10px;
}
.btn-chat-drawer-toggle.active .badge { background: #fff; color: #1a1a1a; }
/* When the drawer is open at narrow widths, scoot the editor toolbar's
content left so the Chat toggle button is not hidden under the drawer.
The toggle has margin-left: auto, so padding-right on the toolbar
effectively shifts it left by the drawer's footprint, keeping "click
toggle again to close" reachable. The @media (min-width: 1280px) block
below resets this. */
.rfc-body.drawer-open .editor-toolbar {
padding-right: calc(min(420px, 92vw) + 8px);
}
@media (min-width: 1280px) {
/* Restore inline three-column layout above the 1280px breakpoint. */
.rfc-body.drawer-open .editor-toolbar { padding-right: 16px; }
.right-panel {
position: static;
top: auto; right: auto; bottom: auto;
width: 360px; flex-shrink: 0;
transform: none;
transition: none;
z-index: auto;
box-shadow: none;
}
.right-panel-backdrop { display: none; }
.btn-chat-drawer-toggle { display: none; }
.editor-toolbar-narrow-only { display: none; }
}
.chat-panel {
+75 -11
View File
@@ -117,6 +117,11 @@ export default function RFCView({ viewer }) {
// {paragraphCount, deadline} null when buffer empty
const [manualCountdown, setManualCountdown] = useState(null)
// Phase 6 narrow-viewport (<1280px) right-column collapse. The drawer
// chrome is rendered unconditionally; CSS hides it above 1280px where
// the right panel is always inline. Per-session, default closed.
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
getRFC(slug).then(setEntry).catch(err => setError(err.message))
listModels(slug)
@@ -441,6 +446,17 @@ export default function RFCView({ viewer }) {
flushManualBuffer()
}, [flushManualBuffer])
// Phase 6 Escape closes the drawer when open. Cheap and gated on
// drawerOpen so the listener attaches only while needed. The CSS hides
// the drawer chrome above 1280px regardless, so this is a no-op there
// because drawerOpen has no visible effect either way.
useEffect(() => {
if (!drawerOpen) return
const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [drawerOpen])
// Editor-click focus matching change card
const handleEditorClick = useCallback((e) => {
const span = e.target.closest('[data-change-id]')
@@ -494,6 +510,13 @@ export default function RFCView({ viewer }) {
const inDiscuss = mode === 'discuss'
const pendingCount = changes.filter(c => c.state === 'pending').length
// Phase 6 count surfaced on the narrow-viewport drawer toggle. Mirrors
// the change-panel-header badge: pending AI changes + the manual card
// when one's buffered.
const drawerBadgeCount = pendingCount + (manualPending ? 1 : 0)
const showContributeToolbar = mode === 'contribute' && canContribute
const showDiscussToolbar = inDiscuss && branchParam !== 'main'
&& changes.some(c => c.state === 'accepted')
// §10.1: the Open PR affordance only surfaces on a non-main branch
// that has commits ahead of main and no open PR already.
@@ -625,7 +648,7 @@ export default function RFCView({ viewer }) {
)}
{/* Two columns: editor + chat */}
<div className="rfc-body">
<div className={`rfc-body${drawerOpen ? ' drawer-open' : ''}`}>
<div className="editor-area" onClick={handleEditorClick}>
{branchParam === 'main' && (
<div className="discuss-mode-banner">
@@ -646,13 +669,7 @@ export default function RFCView({ viewer }) {
creator or an arbiter can grant access.
</div>
)}
{mode === 'contribute' && canContribute && (
<div className="narrow-viewport-banner">
The split-pane Contribute layout works best on screens at least
1280px wide. Resize to a wider window for full rendering.
</div>
)}
{mode === 'contribute' && canContribute && (
{showContributeToolbar && (
<div className="editor-toolbar">
<button
type="button"
@@ -666,10 +683,14 @@ export default function RFCView({ viewer }) {
{changes.filter(c => c.state === 'accepted').length} accepted ·{' '}
{pendingCount} pending
</span>
<ChatDrawerToggleButton
open={drawerOpen}
count={drawerBadgeCount}
onToggle={() => setDrawerOpen(v => !v)}
/>
</div>
)}
{inDiscuss && branchParam !== 'main'
&& changes.some(c => c.state === 'accepted') && (
{showDiscussToolbar && (
<div className="editor-toolbar">
<button
type="button"
@@ -682,6 +703,23 @@ export default function RFCView({ viewer }) {
<span className="editor-toolbar-hint">
{changes.filter(c => c.state === 'accepted').length} accepted on this branch
</span>
<ChatDrawerToggleButton
open={drawerOpen}
count={drawerBadgeCount}
onToggle={() => setDrawerOpen(v => !v)}
/>
</div>
)}
{!showContributeToolbar && !showDiscussToolbar && (
// Phase 6 when neither editing toolbar would render, surface
// a narrow-only toolbar carrying just the drawer toggle. CSS
// hides it above 1280px where the right panel is always inline.
<div className="editor-toolbar editor-toolbar-narrow-only">
<ChatDrawerToggleButton
open={drawerOpen}
count={drawerBadgeCount}
onToggle={() => setDrawerOpen(v => !v)}
/>
</div>
)}
{reviewMode ? (
@@ -745,7 +783,13 @@ export default function RFCView({ viewer }) {
)}
</div>
<div className="right-panel">
<div
className="right-panel-backdrop"
onClick={() => setDrawerOpen(false)}
aria-hidden="true"
data-open={drawerOpen ? 'true' : 'false'}
/>
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
<ChatPanel
messages={messages}
threads={branchView.threads || []}
@@ -853,6 +897,26 @@ export default function RFCView({ viewer }) {
)
}
function ChatDrawerToggleButton({ open, count, onToggle }) {
// Phase 6 toolbar button that opens/closes the right-panel drawer
// at narrow viewports. The badge mirrors the change-panel-header's
// .badge styling; an aria-label gives screen readers the count. CSS
// hides the button above 1280px where the right panel is inline.
return (
<button
type="button"
className={`btn-chat-drawer-toggle${open ? ' active' : ''}`}
onClick={onToggle}
aria-pressed={open}
aria-label={`${open ? 'Close' : 'Open'} chat panel${count > 0 ? ` (${count} pending)` : ''}`}
title={open ? 'Close chat panel' : 'Open chat panel'}
>
Chat
{count > 0 && <span className="badge">{count}</span>}
</button>
)
}
function focusMessage(messageId) {
const el = document.querySelector(`[data-message-id="${messageId}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })