- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024) - Add ViewportInfo InheritedWidget for layout-aware descendants - Add inline ChatPanel (380dp right column) for expanded desktop layout - Add channel right-click context menu with Chat option for in-place switching - Add per-target draft persistence via restoredDraft/onDraftChanged callbacks - Fix header chat button to switch to current voice channel when panel open - Fix close = dismiss (preserves last target and draft for reopen) - Add unread dot indicator on channel tiles when chat is closed - Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz) to avoid buffer underruns on macOS CoreAudio real-time thread - Add tools/build-macos.sh release build script (7-step process) - Add chat panel switching implementation plan and 3-panel design spec Tests: 183 passed, 2 skipped. Flutter analyze clean.
12 KiB
Chanora Adaptive 3-Panel Layout Design
Date: 2026-06-05 Status: Draft Scope: Desktop adaptive layout for ≥1024dp three-panel mode, centralized breakpoint system, and chat panel integration.
1. Problem Statement
Chanora's current responsive layout uses a single breakpoint (_wideBreakpoint = 600dp) scattered across 9 files in 8 duplicatable clusters (5 LayoutBuilder sites, 7 MediaQuery.sizeOf sites). The desktop layout is a 2-panel split (VoiceBar 320px + SnapshotView flex) with no persistent chat surface.
Research across Discord, Mattermost, Rocket.Chat, Element, and hardware resolution data shows:
- 1024dp is the industry-standard threshold where a third panel becomes viable (Discord member list, Mattermost RHS, Rocket.Chat contextual bar all use this value).
- At 1024dp, Chanora's math works:
320 + 12 + 300 + 12 + 380 = 1024— minimum viable for VoicePanel + ChannelTree + ChatPanel. - Production apps use push/replace navigation for chat on constrained widths, reserving persistent panels for ≥1024dp.
- Centralized breakpoint logic is standard practice (Rocket.Chat
LayoutProvider, MattermostWindowSizes).
2. Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| 3-panel activation threshold | 1024dp | Industry consensus (Discord, Mattermost, Rocket.Chat). Chanora math: center pane = 300dp minimum. |
| Chat behavior at 600–1023dp | Push route (unchanged) | Research validates current pattern. Overlays are for contextual info, not primary conversation. |
| Chat behavior at ≥1024dp | Inline panel | Chat renders in a 380dp right panel alongside the channel tree. No route push. |
| Centralized breakpoints | New ChanoraBreakpoints + ViewportInfo |
Replaces 8 duplicated responsive clusters with single source of truth. |
| Architecture approach | Adaptive Scaffold Shell | Extends existing widget tree with centralized layout logic. Not a full rewrite. |
| AdaptiveScaffold package | Not used | Package discontinued (flutter/flutter#162965). Manual layout gives better control for voice-first UX. |
3. Breakpoint System
3.1 Layout Classes
Three tiers, aligned with Material 3 adaptive guidance:
| Class | Width Range | Primary Behavior |
|---|---|---|
compact |
< 600dp | Single column. VoiceStatusChip at bottom. Chat as pushed route. |
medium |
600–1023dp | 2-panel row (VoicePanel 320px + SnapshotView flex). Chat as pushed route. |
expanded |
≥ 1024dp | 3-panel row (VoicePanel 320px + SnapshotView flex + ChatPanel 380px). Chat inline. |
3.2 New Files
lib/design/breakpoints.dart — canonical breakpoint tokens:
class ChanoraBreakpoints {
static const double compact = 0;
static const double medium = 600;
static const double expanded = 1024;
static const double voicePanelWidth = 320;
static const double chatPanelWidth = 380;
static const double panelGap = 12;
}
enum LayoutClass { compact, medium, expanded }
LayoutClass layoutClassFromWidth(double width) {
if (width >= ChanoraBreakpoints.expanded) return LayoutClass.expanded;
if (width >= ChanoraBreakpoints.medium) return LayoutClass.medium;
return LayoutClass.compact;
}
lib/design/viewport_info.dart — inherited widget that computes layout class once per frame:
class ViewportInfo extends InheritedWidget {
const ViewportInfo({
super.key,
required this.layoutClass,
required this.width,
required this.height,
required super.child,
});
final LayoutClass layoutClass;
final double width;
final double height;
static ViewportInfo of(BuildContext context) {
final info = context.dependOnInheritedWidgetOfExactType<ViewportInfo>();
assert(info != null, 'No ViewportInfo found in widget tree');
return info!;
}
bool get isCompact => layoutClass == LayoutClass.compact;
bool get isMedium => layoutClass == LayoutClass.medium;
bool get isExpanded => layoutClass == LayoutClass.expanded;
@override
bool updateShouldNotify(ViewportInfo old) =>
layoutClass != old.layoutClass ||
width != old.width ||
height != old.height;
}
3.3 What This Replaces
The audit identified these duplicatable clusters that get consolidated:
| Cluster | Current Locations | Replacement |
|---|---|---|
| 600dp breakpoint (×3) | main.dart:304,2280,2468 |
ChanoraBreakpoints.medium |
| 400px cap (×2) | connect_widgets.dart, voice_settings.dart |
Named token in ChanoraBreakpoints |
| 72% modal height (×2) | audio_output_tile.dart, ptt_capability_badge.dart |
Named token |
| 320px voice bar width | main.dart:2467 |
ChanoraBreakpoints.voicePanelWidth |
| Platform capability branching | voice_settings.dart, voice_compact.dart, audio_processing_config_state.dart |
Centralized capability helper |
4. Adaptive Shell
4.1 Widget Tree
The existing _BetaHome widget tree is restructured to use ViewportInfo:
_BetaHome
├─ macOS: Scaffold with traffic-light padding (unchanged)
├─ Mobile: ChanoraMobileScaffold (unchanged)
└─ bodyContent:
└─ LayoutBuilder
└─ ViewportInfo (computes layoutClass from constraints)
├─ compact: Column [SnapshotView, VoiceStatusChip, PTT]
├─ medium: Row [VoicePanel, SnapshotView]
└─ expanded: Row [VoicePanel, SnapshotView, ChatPanel]
AdaptiveShell is a pure layout widget — it reads ViewportInfo and composes the appropriate children. All state remains in _BetaHome.
4.2 Platform Handling
Platform-specific scaffolding stays at the top level, unchanged:
- macOS:
Scaffoldwith_macOSTrafficLightPadtop padding (28dp) - Mobile:
ChanoraMobileScaffoldwith compact idle chrome - Windows/Linux: Default
Scaffold
The ViewportInfo + layout switch only affects the body content inside the scaffold.
5. Chat Panel Behavior
5.1 Compact (< 600dp)
No change. Chat opens as a pushed MaterialPageRoute:
main.dart:_onOpenChat → Navigator.push(ChatPage)
Channel tree is fully replaced. Back button returns to main view.
5.2 Medium (600–1023dp)
Same as compact. Chat is a pushed route. The 2-panel layout (VoicePanel + SnapshotView) stays as the home screen.
5.3 Expanded (≥ 1024dp)
Chat renders inline in a 380dp right panel. The flow:
- User taps "Open Text Chat" on a client, or taps the chat badge
_onOpenChatreadsViewportInfo.of(context).isExpanded- If expanded: sets
_inlineChatTargetstate →ChatPanelrenders in the third column - If not expanded: pushes
ChatPageroute (unchanged behavior)
5.4 ChatPanel Widget
New widget for ≥1024dp only:
ChatPanel (380dp fixed width)
├─ Header: target name + close button
├─ Message list (scrollable, max-width ~500dp for readability)
└─ Input field
State sharing: The _chatMessages list and _chatFeedRevision listenable in _BetaHome already track all messages. ChatPanel reads from the same source — no duplication.
Close behavior: User taps close button → _inlineChatTarget set to null → ChatPanel removed from tree.
5.5 Width Transition
When the user resizes from ≥1024dp to <1024dp while chat is open inline:
ChatPaneldisappears (it's only in the expanded layout branch)- A brief snackbar appears: "Tap the chat button to continue your conversation"
- The
_inlineChatTargetstate is preserved — tapping the chat button reopens the pushedChatPageroute with the same target
This matches Discord's behavior when the member list collapses on resize.
6. Panel Sizing
| Element | Width | Behavior |
|---|---|---|
| VoicePanel (left) | 320dp fixed | VoiceBar, connection status, PTT controls. Unchanged. |
| Panel gaps | 12dp | Between each panel. Unchanged. |
| SnapshotView (center) | flex (1fr) | Grows to fill remaining space. |
| ChatPanel (right) | 380dp fixed | Only rendered at ≥1024dp. |
| Chat messages | max-width ~500dp | Centered within ChatPanel for readability. |
| macOS traffic light pad | 28dp top | Unchanged. Only affects height. |
Center pane widths at common viewports:
| Viewport | Center Width | Feel |
|---|---|---|
| 1024dp | 300dp | Minimum viable (matches Discord at same width) |
| 1200dp | 476dp | Comfortable |
| 1280dp | 556dp | Spacious (Chanora's default window size) |
| 1440dp | 716dp | Very spacious |
| 1920dp | 1184dp | Ultra-wide — consider capping center max-width post-MVP |
7. Migration Map
| File | Change | Scope |
|---|---|---|
lib/design/breakpoints.dart |
New — breakpoint tokens + LayoutClass enum |
New file |
lib/design/viewport_info.dart |
New — ViewportInfo inherited widget |
New file |
lib/main.dart |
Replace _wideBreakpoint = 600.0 with ChanoraBreakpoints.medium. Wrap body in ViewportInfo. Add _inlineChatTarget state. Branch _onOpenChat for expanded vs compact/medium. Add ChatPanel to expanded Row. |
Significant |
lib/widgets/chat_views.dart |
Replace _chatMobileBreakpoint with ChanoraBreakpoints.medium. No structural changes. |
Token swap |
lib/widgets/connect_widgets.dart |
Replace hardcoded 400px with token. | Token swap |
lib/widgets/app_snack_bar.dart |
Replace hardcoded 600/560px with tokens. | Token swap |
lib/widgets/snapshot_view.dart |
No changes. Local spacer math stays local. | None |
lib/widgets/voice_compact.dart |
Replace platform branching with centralized helper (optional, post-MVP). | Optional |
Unchanged: macOS scaffold, ChanoraMobileScaffold, all voice controls, channel tree, chat route for compact/medium, all Rust bridge code.
8. Hardware Coverage
The 1024dp threshold coverage based on 2026 resolution data:
| Setup | Logical Width | Sees 3-Panel? |
|---|---|---|
| 1920×1080 @100% fullscreen | 1920dp | Yes |
| 1920×1080 @125% fullscreen | 1536dp | Yes |
| 1920×1080 @150% fullscreen | 1280dp | Yes |
| 1366×768 @100% fullscreen | 1366dp | Yes |
| 1366×768 @125% fullscreen | 1093dp | Yes |
| 1366×768 @125% windowed (~85%) | ~930dp | No (2-panel) |
| 2560×1440 @100% half-screen | ~1280dp | Yes |
| 2560×1440 @125% half-screen | ~1024dp | Yes (edge) |
| MacBook 13" Split View | ~708dp | No (2-panel) |
| MacBook 14" Split View | ~744dp | No (2-panel) |
| MacBook 16" Split View | ~852dp | No (2-panel) |
Chanora's default window (1280×720 on Windows/Linux) starts in 3-panel mode immediately.
9. Out of Scope (Post-MVP)
- Resizable panels (drag-to-resize VoicePanel/ChatPanel width)
- NavigationRail for ultra-wide monitors
- ChatPanel showing user profile or channel info
- Center pane max-width cap for ultra-wide monitors
- Centralized platform capability helper (consolidating voice_settings/voice_compact/audio_processing branching)
- Animated transitions between layout classes
- ChatPanel as a sheet/drawer on medium widths
10. References
- Discord member list collapse at 1024px: compact-discord
- Mattermost RHS persistent at ≥1024px: structure.scss
- Rocket.Chat contextual bar persistent at ≥1024px (lg breakpoint): fuselage-tokens
- Flutter AdaptiveScaffold discontinued: flutter/flutter#162965
- Material 3 canonical breakpoints: m3.material.io/foundations/layout
- Chanora adaptive layout policy: docs/ui-ux/adaptive-layout-platform-guide.md