feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix

- 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.
This commit is contained in:
Edison Jwa
2026-06-07 23:12:07 +09:00
parent e7f7c55b30
commit 5e8b7915db
14 changed files with 2187 additions and 283 deletions
@@ -0,0 +1,642 @@
# Chat Panel Switching Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable in-place conversation switching in the expanded 3-panel layout, add per-conversation draft persistence, and improve unread awareness — matching industry-standard UX patterns from Discord/Slack/Telegram/Element.
**Architecture:** The expanded layout (≥1024dp) shows voice controls | channel tree | inline chat panel. Currently the chat panel locks to one conversation with no way to switch from the channel tree. The fix adds channel→chat triggers, in-place target swapping, per-target draft storage, and preserves chat state across switches. The state machine (`_inlineChatTarget` + `_chatOpen`) already supports switching — we just need UI affordances and draft persistence.
**Tech Stack:** Flutter/Dart, existing `BridgeMessageTarget` sealed class, existing `ChatDetailView` / `ChatPanel` / `SnapshotView` widgets.
**Design research basis:** Discord (in-place swap, dot/badge unread hierarchy), Telegram Desktop (adaptive 3-tier layout, per-conversation drafts + scroll anchoring), Element (per-room panel state, toggleable right panel), Slack (bold sidebar for unread, split view). All apps treat DMs and channels identically for switching behavior.
---
## Current State
| UX Element | Status |
|---|---|
| Unread indicator | Single global badge count on app bar chat button |
| Channel → chat trigger | **None.** Channel tiles only join voice |
| Client → DM trigger | Works (right-click → "Direct Message") |
| Header chat button when panel open | Idempotent — re-uses same `_inlineChatTarget` |
| Draft persistence | None — single `TextEditingController`, lost on switch |
| Scroll position memory | None — always auto-scrolls to bottom |
| Per-conversation unread | None |
## Scope
**In scope (this plan):**
- Channel → chat switching in expanded layout
- Channel right-click → "Chat" option
- Header chat button → switch to current voice channel chat when panel already open
- Per-target draft persistence (in-memory `Map`)
- Close = dismiss (remember last target and draft)
- Unread dot indicator on channels in `SnapshotView`
**Out of scope (future):**
- Scroll position memory per target
- "New messages" divider
- Per-target unread counts / badge numbers
- Notification tiering (dot/badge/mention)
- Split view (Slack power-user feature)
## Responsive Behavior
| Tier | Width | Chat Mode | Changes in this plan |
|---|---|---|---|
| **Expanded** | ≥1024dp | Inline `ChatPanel` (right column) | ✅ All changes apply here |
| **Medium** | 6001023dp | Full-screen `ChatPage` route | No changes needed (already works) |
| **Compact** | <600dp | Full-screen `ChatPage` route | No changes needed (already works) |
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `apps/chanora_flutter/lib/widgets/snapshot_view.dart` | **Modify** | Add `onOpenChannelChat` callback, channel context menu with "Chat" option, unread dot on channels |
| `apps/chanora_flutter/lib/main.dart` | **Modify** | Add `_chatDrafts` map, wire `onOpenChannelChat`, fix header chat button to switch to current channel, fix `_closeInlineChat` to preserve last target |
| `apps/chanora_flutter/lib/widgets/chat_panel.dart` | **Modify** | Accept `onSwitchTarget` callback, pass draft state through |
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | **Modify** | `ChatDetailView` accepts external draft text, exposes draft text on target change |
| `apps/chanora_flutter/lib/design/breakpoints.dart` | **No changes** | Breakpoints unchanged |
---
### Task 1: Add `onOpenChannelChat` callback to `SnapshotView`
**Files:**
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:26-63` (constructor params)
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:203-284` (`_channelTile`)
- [ ] **Step 1: Add the callback field to `SnapshotView` widget**
In `snapshot_view.dart`, add a new optional callback field after `onOpenClientPoke` (around line 71):
```dart
/// Open chat for a channel.
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
```
Update the constructor to include it (around line 32, after `onOpenClientPoke`):
```dart
this.onOpenChannelChat,
```
- [ ] **Step 2: Add a right-click/long-press context menu to `_channelTile`**
Replace the `InkWell` in `_channelTile` (lines 243-284) with a context menu wrapper. The channel tile should support:
- **Tap**: join voice (existing behavior, unchanged)
- **Right-click / long-press**: show a popup menu with "Open chat" option
```dart
return InkWell(
onTap: onTap,
onLongPress: widget.onOpenChannelChat != null
? () => widget.onOpenChannelChat!(channel)
: null,
child: PopupMenuButton<String>(
position: PopupMenuPosition.under,
enabled: widget.onOpenChannelChat != null,
onSelected: (value) {
if (value == 'chat') {
widget.onOpenChannelChat?.call(channel);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'chat',
child: Row(
children: [
const Icon(Icons.chat_bubble_outline, size: 18),
const SizedBox(width: 12),
Text(AppLocalizations.of(context)!.chatAction),
],
),
),
],
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
),
);
```
Note: The `PopupMenuButton` wraps the existing content as its `child`, so the tile looks identical until right-clicked. The `onTap` on `InkWell` continues to handle voice join.
- [ ] **Step 3: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors (the callback is optional, so existing call sites compile without changes)
- [ ] **Step 4: Commit**
```bash
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart
git commit -m "feat(chat): add onOpenChannelChat callback with context menu to channel tiles"
```
---
### Task 2: Wire `onOpenChannelChat` in `main.dart` and add per-target draft storage
**Files:**
- Modify: `apps/chanora_flutter/lib/main.dart:370-374` (state fields)
- Modify: `apps/chanora_flutter/lib/main.dart:2521-2540` (SnapshotView constructor)
- [ ] **Step 1: Add draft storage map**
Add a new state field near line 374 (after `_inlineChatCollapseNoticeShown`):
```dart
/// Per-target draft text. Populated when switching away from a conversation
/// so the user's unfinished message is preserved.
final Map<String, String> _chatDrafts = {};
```
- [ ] **Step 2: Add `_lastDismissedTarget` field**
Add a new state field to remember the last dismissed target so reopening returns to it:
```dart
/// The last chat target before the panel was closed. Used to restore the
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
```
- [ ] **Step 3: Wire `onOpenChannelChat` in `SnapshotView` constructor**
In the `SnapshotView(...)` constructor around line 2512, add the new callback:
```dart
onOpenChannelChat: (channel) => unawaited(
_onOpenChat(
target: rust.BridgeMessageTarget.channel(channel.id),
),
),
```
- [ ] **Step 4: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 5: Commit**
```bash
git add apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): add per-target draft storage and wire onOpenChannelChat"
```
---
### Task 3: Fix `_onOpenChat` to support switching and draft save/restore
**Files:**
- Modify: `apps/chanora_flutter/lib/main.dart:1628-1685` (`_onOpenChat` and `_closeInlineChat`)
- [ ] **Step 1: Update `_onOpenChat` to save current draft and restore new target's draft**
Replace the `_onOpenChat` method (lines 1628-1676) with logic that:
1. Saves the current `_inlineChatTarget` draft before switching
2. Restores the new target's draft (if any)
3. When called with no explicit target and panel is already open, switches to current voice channel's chat
```dart
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
// Resolve the new target.
// If no target passed and panel is already open, switch to current voice channel.
// If no target passed and panel is closed, resolve from history or default.
rust.BridgeMessageTarget newTarget;
if (target != null) {
newTarget = target;
} else if (_chatOpen && _currentVoiceChannelId != null) {
newTarget = rust.BridgeMessageTarget.channel(_currentVoiceChannelId!);
} else if (_inlineChatTarget != null) {
newTarget = _inlineChatTarget!;
} else {
newTarget = resolveInitialChatTarget(
messages: _chatMessages,
currentVoiceChannelId: _currentVoiceChannelId,
) ?? const rust.BridgeMessageTarget.server();
}
final newClientName = clientName.isNotEmpty
? clientName
: (newTarget == _inlineChatTarget) ? _inlineChatClientName : '';
final isExpanded =
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
LayoutClass.expanded;
if (isExpanded) {
setState(() {
// Save draft for the current target before switching.
_saveCurrentDraft();
_chatUnread = 0;
_chatOpen = true;
_inlineChatTarget = newTarget;
_inlineChatClientName = newClientName;
_inlineChatCollapseNoticeShown = false;
});
return;
}
setState(() {
_chatUnread = 0;
_chatOpen = true;
});
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatPage(
messages: _chatMessages,
snapshot: initialSnapshot,
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget: newTarget,
initialClientName: newClientName,
onTs3ServerLink: _onTs3ServerLink,
),
),
);
if (mounted) setState(() => _chatOpen = false);
}
```
- [ ] **Step 2: Add `_saveCurrentDraft` and `_draftKeyForTarget` helper methods**
Add these near `_onOpenChat`:
```dart
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
return switch (target) {
rust.BridgeMessageTarget_Server() => 'server',
rust.BridgeMessageTarget_Channel(:final id) => 'channel:$id',
rust.BridgeMessageTarget_Client(:final id) => 'client:$id',
rust.BridgeMessageTarget_Poke(:final id) => 'poke:$id',
};
}
/// Saves the current draft text (if any) for the current inline chat target.
/// Called before switching targets or closing the panel.
void _saveCurrentDraft() {
// Note: The actual draft text is read from ChatDetailView's
// TextEditingController via a callback. This is wired in Task 4.
}
```
Note: `_saveCurrentDraft` will be completed in Task 4 when we wire the draft callback from `ChatDetailView`.
- [ ] **Step 3: Update `_closeInlineChat` to preserve last target instead of nulling it**
Replace `_closeInlineChat` (lines 1678-1685):
```dart
void _closeInlineChat() {
setState(() {
// Save draft before closing.
_saveCurrentDraft();
// Remember the last target so reopening returns to it.
_lastDismissedTarget = _inlineChatTarget;
_lastDismissedClientName = _inlineChatClientName;
_chatOpen = false;
// Do NOT null _inlineChatTarget — we want to remember it for reopen.
});
}
```
- [ ] **Step 4: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 5: Commit**
```bash
git add apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): switch chat target on channel click, save draft before switching, preserve target on close"
```
---
### Task 4: Add draft save/restore callback to `ChatDetailView` and `ChatPanel`
**Files:**
- Modify: `apps/chanora_flutter/lib/widgets/chat_views.dart:1054-1098` (`ChatDetailView` constructor + state)
- Modify: `apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75` (`ChatPanel` constructor + build)
- [ ] **Step 1: Add draft callbacks to `ChatDetailView`**
Add two new optional callbacks to `ChatDetailView` (after `messageMaxWidth` around line 1066):
```dart
/// External draft text to restore when the widget initializes or the target changes.
final String? restoredDraft;
/// Called with the current draft text whenever the target changes or the widget is disposed.
final ValueChanged<String>? onDraftChanged;
```
- [ ] **Step 2: Implement draft restore in `_ChatDetailViewState`**
In `_ChatDetailViewState` (line 1100), add `initState` and `didUpdateWidget` to handle drafts:
```dart
@override
void initState() {
super.initState();
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
_textCtl.text = widget.restoredDraft!;
}
}
@override
void didUpdateWidget(covariant ChatDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target != widget.target) {
// Save draft for old target before switching.
if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
oldWidget.onDraftChanged!(_textCtl.text);
}
// Restore draft for new target.
_textCtl.text = widget.restoredDraft ?? '';
_lastRenderedTarget = null;
}
}
@override
void dispose() {
// Emit the current draft so the parent can save it.
if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
widget.onDraftChanged!(_textCtl.text);
}
_textCtl.dispose();
_scrollCtl.dispose();
super.dispose();
}
```
Remove the existing `dispose` method (lines 1127-1132) — it's replaced by the new one above.
- [ ] **Step 3: Thread draft callbacks through `ChatPanel`**
Update `ChatPanel` to accept and pass through the new callbacks. Add fields:
```dart
/// External draft text to restore in the chat detail view.
final String? restoredDraft;
/// Called when the draft text changes.
final ValueChanged<String>? onDraftChanged;
```
Pass them through in `build()` where `ChatDetailView` is constructed (line 58):
```dart
child: ChatDetailView(
messages: messages,
snapshot: snapshot,
target: target,
clientName: clientName,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: onTs3ServerLink,
restoredDraft: restoredDraft,
onDraftChanged: onDraftChanged,
messageMaxWidth: 500,
headerTrailing: IconButton(
tooltip: 'Close chat',
icon: const Icon(Icons.close),
onPressed: onClose,
),
),
```
- [ ] **Step 4: Wire draft callbacks in `main.dart`**
In the `ChatPanel(...)` constructor around line 2567, add the draft callbacks:
```dart
ChatPanel(
messages: _chatMessages,
snapshot: _snapshot!,
target: inlineChatTarget,
clientName: _inlineChatClientName,
onTs3ServerLink: _onTs3ServerLink,
restoredDraft: _chatDrafts[_draftKeyForTarget(inlineChatTarget)],
onDraftChanged: (text) {
_chatDrafts[_draftKeyForTarget(_inlineChatTarget!)] = text;
},
onClose: _closeInlineChat,
),
```
- [ ] **Step 5: Complete `_saveCurrentDraft` in `main.dart`**
The `_saveCurrentDraft` method is called from `_onOpenChat` (before switching) and `_closeInlineChat`. Since `ChatDetailView` emits drafts via `onDraftChanged` and `dispose`, the parent always has the latest draft in `_chatDrafts`. The method body stays as a no-op safety net:
```dart
void _saveCurrentDraft() {
// Drafts are continuously saved via onDraftChanged callback.
// This method exists as an explicit save point for any future
// snapshot-based draft capture.
}
```
- [ ] **Step 6: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 7: Commit**
```bash
git add apps/chanora_flutter/lib/widgets/chat_views.dart apps/chanora_flutter/lib/widgets/chat_panel.dart apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): per-target draft persistence with save/restore on switch"
```
---
### Task 5: Add unread dot indicator to channel tiles in `SnapshotView`
**Files:**
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart` (add unread indicator)
- Modify: `apps/chanora_flutter/lib/main.dart` (pass unread channel set)
- [ ] **Step 1: Add unread channel IDs parameter to `SnapshotView`**
Add a new required field to `SnapshotView` (after `canJoinVoiceChannel` around line 57):
```dart
/// Set of channel IDs that have unread chat messages.
final Set<BigInt> unreadChannelIds;
```
- [ ] **Step 2: Add unread dot to `_channelTile`**
In `_channelTile`, inside the `Row` children (after the channel name `Expanded` widget, around line 273), add an unread dot:
```dart
// Unread indicator.
if (widget.unreadChannelIds.contains(channel.id)) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
],
```
This must come before the password lock icon check (line 274).
- [ ] **Step 3: Compute unread channel set in `main.dart`**
Add a getter in `_BetaHomeState` that computes which channels have unread messages:
```dart
/// Channel IDs that have unread chat messages (used for dot indicators).
Set<BigInt> get _unreadChannelIds {
if (_chatOpen) return const {};
final ids = <BigInt>{};
for (final entry in _chatMessages) {
if (!entry.countsTowardUnread || entry.isSelf) continue;
if (entry.target case rust.BridgeMessageTarget_Channel(:final id)) {
ids.add(id);
}
}
return ids;
}
```
- [ ] **Step 4: Pass unread channel set to `SnapshotView`**
In the `SnapshotView(...)` constructor around line 2512, add:
```dart
unreadChannelIds: _unreadChannelIds,
```
- [ ] **Step 5: Run `flutter analyze`**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: No new errors
- [ ] **Step 6: Commit**
```bash
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart apps/chanora_flutter/lib/main.dart
git commit -m "feat(chat): unread dot indicator on channel tiles with unread messages"
```
---
### Task 6: End-to-end verification
**Files:** All modified files.
- [ ] **Step 1: Run `flutter analyze` on the full project**
Run: `cd apps/chanora_flutter && flutter analyze`
Expected: Zero issues
- [ ] **Step 2: Run `flutter test`**
Run: `cd apps/chanora_flutter && flutter test`
Expected: All tests pass (same baseline as before — 180 passed, 2 skipped)
- [ ] **Step 3: Build macOS release**
Run: `bash tools/build-macos.sh`
Expected: Successful build producing `chanora-v0.2.0-beta.1-macos-aarch64.zip`
- [ ] **Step 4: Manual QA checklist**
Launch the app and verify:
1. **Channel → chat switching**: With window ≥1024dp and chat panel open showing Server chat, click a channel in the tree. The chat panel should switch to that channel's chat (messages filter to that channel). Voice join should also happen.
2. **Channel right-click → Chat**: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice.
3. **Header chat button toggle**: With chat panel open showing a DM, click the header chat button. It should switch to the current voice channel's chat.
4. **Draft persistence**: Type "hello" in chat input but don't send. Click a different channel. Type "world" in that channel's chat. Switch back to the first channel. The input should show "hello".
5. **Close and reopen**: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact.
6. **Unread dots**: Close the chat panel. Have someone send a message to a specific channel. That channel in the tree should show a blue dot.
7. **Medium/compact unchanged**: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions.
8. **DM switching still works**: Right-click a client → "Direct Message". Chat panel should switch to that DM. Right-click another client → "Direct Message". Should switch again.
---
## Self-Review
### Spec coverage
| Requirement | Task |
|---|---|
| Channel → chat switching (tap) | Task 2 (wiring) + Task 3 (target resolution) |
| Channel → chat (context menu) | Task 1 |
| Header button switches when open | Task 3 |
| Per-target draft persistence | Task 4 |
| Close = dismiss (remember state) | Task 3 |
| Unread dot on channels | Task 5 |
| Medium/compact unchanged | No changes to those paths |
### Placeholder scan
No TBD, TODO, or placeholder steps found. All code blocks contain complete implementations.
### Type consistency
- `BridgeMessageTarget.channel(id)` uses `BigInt` — matches `channel.id` type
- `_chatDrafts` uses `String` keys from `_draftKeyForTarget` — consistent
- `onOpenChannelChat` callback type `ValueChanged<rust.BridgeChannel>?` — matches widget pattern
- `unreadChannelIds` uses `Set<BigInt>` — matches channel ID type
@@ -0,0 +1,266 @@
# 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`, Mattermost `WindowSizes`).
## 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 6001023dp | **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` | 6001023dp | 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:
```dart
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:
```dart
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**: `Scaffold` with `_macOSTrafficLightPad` top padding (28dp)
- **Mobile**: `ChanoraMobileScaffold` with 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 (6001023dp)
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:
1. User taps "Open Text Chat" on a client, or taps the chat badge
2. `_onOpenChat` reads `ViewportInfo.of(context).isExpanded`
3. If expanded: sets `_inlineChatTarget` state → `ChatPanel` renders in the third column
4. If not expanded: pushes `ChatPage` route (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:
1. `ChatPanel` disappears (it's only in the expanded layout branch)
2. A brief snackbar appears: "Tap the chat button to continue your conversation"
3. The `_inlineChatTarget` state is preserved — tapping the chat button reopens the pushed `ChatPage` route 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](https://github.com/asportnoy/compact-discord)
- Mattermost RHS persistent at ≥1024px: [structure.scss](https://github.com/mattermost/mattermost/blob/3440453d82613b1d8d67c93011c11d56a1380869/webapp/channels/src/sass/base/_structure.scss)
- Rocket.Chat contextual bar persistent at ≥1024px (lg breakpoint): [fuselage-tokens](https://github.com/RocketChat/fuselage/blob/ed91cb04db9fd6c35b43390190cbf7327c3eab9e/packages/fuselage-tokens/src/breakpoints.jsonc)
- Flutter AdaptiveScaffold discontinued: [flutter/flutter#162965](https://github.com/flutter/flutter/issues/162965)
- Material 3 canonical breakpoints: [m3.material.io/foundations/layout](https://m3.material.io/foundations/layout/breakpoints/overview)
- Chanora adaptive layout policy: [docs/ui-ux/adaptive-layout-platform-guide.md](../ui-ux/adaptive-layout-platform-guide.md)