diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f8b6de..03d028f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
## [Unreleased]
+### Changed
+
+- Reorganized recording details into a full-height reading workspace with a
+ compact contextual toolbar, document/version selectors, bottom audio player,
+ a collapsible recording list controlled from the left of the detail title,
+ and an on-demand generation-details drawer. Existing
+ generation, regeneration, AI revision and transcription dialogs retain their
+ fields and behavior.
+
+### Fixed
+
+- Unified the sidebar and tray with Nota's packaged application icon. Idle
+ shows the app mark; recording adds a lower-right red badge, while pause and
+ capture warnings retain distinct status badges and existing tray controls.
+- Unified AI generation close and JSON disclosure icons with Lucide. Generation
+ dialogs and the details drawer now support backdrop dismissal without closing
+ on text-selection drags; generation submission consistently blocks dismissal.
+- Kept AI generation form scrolling inside the rounded dialog shell, with the
+ header and actions visible and no scrollbar covering the corners.
+
## [0.9.0] - 2026-09-02
### Changed
diff --git a/docs/ai-documents.md b/docs/ai-documents.md
index cd707e8..f88b2d3 100644
--- a/docs/ai-documents.md
+++ b/docs/ai-documents.md
@@ -110,8 +110,8 @@ contain credentials, prompt bodies, transcript content, or model output.
Provider-controlled JSON verbatim but Nota never adds runtime credentials to
it. Both snapshots are loaded by React only when the user opens **Generation
details**.
-- The document preview has top-level **Document** and **Generation details**
- tabs. Generation details shows normalized usage plus separate request and
+- The document preview keeps the body visible and opens **Generation details**
+ in a right-hand drawer. Generation details shows normalized usage plus separate request and
response JSON trees. Historical versions created before snapshot persistence
explicitly report that their original JSON is unavailable.
diff --git a/docs/architecture.md b/docs/architecture.md
index 62e5303..34bb3a0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -76,6 +76,39 @@ flowchart LR
| Configured ASR service | Model inference and server-side processing | Local recording ownership |
| Configured LLM provider | Explicitly requested text generation | Local Markdown, SQLite, or recording ownership |
+### Recording Detail Presentation
+
+`RecordingsWorkspace` owns selected-record presentation, native audio playback,
+transcription actions, focus mode, and the existing speaker/transcription
+dialogs. `AiDocumentsPanel` remains the sole owner of AI workspace reads,
+generation drafts, request estimates, version events, file actions, and all
+three generation-dialog modes. Its toolbar, Markdown reader, and generation
+details drawer are presentation components, not additional IPC owners.
+
+The AI panel mounts on first visit and remains mounted while its tab is hidden.
+The audio element, record list, and transcript scroller also retain their DOM
+identity across tab/focus changes. Markdown reading positions are keyed by
+version in transient component state; they are not stored in SQLite or settings.
+Leaving the workspace clears this presentation state. Recording changes retain
+the existing AI reset behavior and asynchronous identity guards.
+
+```mermaid
+flowchart LR
+ A[App: workspace shell] --> B[RecordingsWorkspace: audio and detail tabs]
+ B --> C[AiDocumentsPanel: selected document and version]
+ C --> D[Toolbar: user intent callbacks]
+ C --> E[Reader: Markdown and transient scroll positions]
+ D --> F[Open generation details drawer]
+ F --> G[Read persisted details for selected version]
+ G --> H[Apply only if request and version are current]
+ D --> I[Existing create / regenerate / revise dialog]
+ I --> J[Existing preview, validation and explicit generation IPC]
+```
+
+Opening tabs, reading documents, and inspecting persisted details do not invoke
+LLM generation. No new backend commands, schemas, credentials, or network paths
+are introduced by the workspace layout.
+
### Settings Ownership and Persistence
`App.tsx` owns the canonical settings and Provider summaries shared with the
@@ -253,6 +286,26 @@ right corner. Content beyond the upper bound remains scrollable, so DPI,
accessibility text scaling, long target titles, and error details cannot hide
the decision buttons.
+## Application Identity and Tray Rendering
+
+The frontend sidebar imports the packaged app-icon PNG as a build asset, and
+Rust embeds the 32 px variant with `tauri::include_image!`. Neither path needs
+runtime filesystem access, IPC image payloads, or an extra image dependency.
+The tray compositor always starts from the original app mark, adding only a
+lower-right anti-aliased badge when the state needs one. Returning to idle
+restores the original pixels; badges never accumulate across transitions.
+Recording preparation/finalization, pause and capture-warning distinctions
+remain visible. Existing state deduplication, main-thread tray updates, menus,
+tooltips and recording control ownership are unchanged.
+
+```mermaid
+flowchart LR
+ S["Recording state + pending capture decision"] --> B["Choose no badge / recording / paused / warning"]
+ I["Packaged 32 px application icon"] --> C["Compose corner badge from original pixels"]
+ B --> C
+ C --> T["Update tray icon on main thread"]
+```
+
## Live Microphone Switching
An active recording may replace or disable only its microphone source. The
diff --git a/docs/design-system.md b/docs/design-system.md
index 2d7dfd7..1a96cd1 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -126,6 +126,17 @@ Lucide remains Nota's product icon set. Use 14 px icons in compact controls,
buttons need an accessible name and an `AppTooltip`; visible text buttons do
not need a duplicate tooltip.
+Application identity is not a Lucide action icon. The sidebar imports the
+packaged `src-tauri/icons/128x128.png` directly, with no replacement glyph,
+extra background or clipped silhouette. Installer, window and tray icons use
+the same packaged microphone artwork; the tray embeds `icons/32x32.png`.
+Idle/completed/recovering tray states show the unmodified app mark. Recording,
+preparing and finalizing add a lower-right recording-red badge; paused adds
+a paused-amber badge. Interrupted/error states and pending capture decisions
+show a warning-amber badge, with pending decisions taking priority. Badges have
+an anti-aliased light separator and use native equivalents of the existing
+status color tokens. Existing tooltip/menu text supplies the precise state.
+
| Control | Standard |
|---|---|
| Compact icon button | 28 px minimum visual target, 14 px icon |
@@ -186,6 +197,39 @@ are removed so the settings hierarchy owns the available height.
Settings uses existing semantic surface, border, status, spacing, and focus
roles. The workspace geometry does not introduce a new visual token role.
+### Recording Detail Workspace
+
+The recording library uses the full application height with the main sidebar
+retained and the redundant global header/footer removed. The record list is a
+280 px track (240 px at widths up to 1100 px). The icon button before the detail
+title toggles only that track, with matching tooltip and accessible labels
+“折叠录音列表” / “展开录音列表”. It exposes the list's expanded state and remains
+available when the list is hidden.
+The compact title, detail tabs, contextual toolbar, reading region, and native
+audio player follow DOM order. The list and reading region scroll independently;
+the outer detail pane must not scroll. Playback remains mounted across tabs.
+
+AI documents use shared select styling for document/version selection instead
+of a permanent nested document sidebar. AI revision and copying stay directly
+available; low-frequency file and generation actions use a keyboard-accessible
+disclosure. Generation details open in a right-hand native dialog drawer with
+focus containment and restoration. Generation configuration dialogs retain
+their original fields and controls. Their rounded outer shell clips overflow;
+only the active form/request panel scrolls, leaving the title, tabs and footer
+visible. Scrollbars stay inside the shell padding, clear of its rounded corners.
+Close controls use Lucide `X`; JSON tree disclosure slots use the bundled Lucide
+chevron SVG as a semantic-color CSS mask without replacing tree keyboard behavior.
+Both generation overlays dismiss on primary clicks that start and end on their
+backdrop. Inside clicks and text-selection drags must not dismiss them; generation
+submission disables close, cancel and backdrop dismissal together.
+
+At 1280×800 and 980×640, normal completed-document content targets at least
+70% and 60% of workspace height respectively. Necessary warnings may reduce
+that area. Controls must not wrap into multiple toolbar rows, and long titles,
+models, and paths must not cause page-level horizontal overflow. Use existing
+semantic tokens; do not shrink body type to achieve density. The playback
+footer retains the local-recording/network-use reminder.
+
## Change and Review Checklist
For every visual change:
diff --git a/docs/testing.md b/docs/testing.md
index e9ae65b..bb4a654 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -122,8 +122,8 @@ Automated client coverage must include:
- recording-list context menus exposing **Open containing folder** only for
list items while preserving the three-action detail overflow menu;
-- one sticky recording-detail control region containing the audio player and
- transcription actions, with the transcript body outside that region;
+- compact recording-detail controls above an independently scrolling transcript,
+ with a stable native audio player below the body;
- transcript segment timestamps rendered as zero-padded `HH:MM:SS`, including
meetings longer than one hour;
- valid batch protocol v1 capability parsing;
@@ -267,7 +267,7 @@ Automated coverage for AI documents must include:
- credential-free request JSON matching the submitted body, successful raw
response JSON and normalized usage persistence, nullable-column migration
for historical databases, and on-demand IPC reads;
-- keyboard-operable Document/Generation details and Request/Response tabs,
+- a keyboard-operable Generation details drawer and Request/Response tabs,
collapsible syntax-highlighted JSON, copy actions, long-value wrapping, and
explicit unavailable states for historical versions;
- recording deletion preserving Markdown by default and deleting exact linked
@@ -374,6 +374,48 @@ Real-model and hardware acceptance results should record software versions,
model id, device type, audio duration, and pass/fail observations. They must not
include the recording or transcript in ordinary logs or committed artifacts.
+## Recording Detail Layout Acceptance
+
+### Application Icon Acceptance
+
+- Confirm the sidebar microphone artwork matches the installed Start menu and
+ window icon. It must not render a replacement Lucide action glyph.
+- Check the Windows tray on light/dark taskbars at 100%, 125%, 150% and 200%
+ scaling: idle uses the app mark only; preparing/recording/finalizing add a red
+ lower-right badge. Pause uses amber; capture reminders/errors use warning
+ amber. Check tooltip/menu distinctions and pending-decision priority.
+- Finish recording and confirm the unmodified idle icon returns. Tray show,
+ pause/resume, stop/save and capture-prompt re-entry must still work.
+- Pixel tests verify the badge leaves the rest of the icon unchanged and that
+ idle states exactly match the embedded asset. Browser/pixel tests do not
+ replace this Windows system-tray check.
+
+### Layout and Dialogs
+
+- Verify the full-height recording workspace at 1280×800, 980×640 and Windows
+ 125% scaling: persistent app navigation, independent list/body scrolling,
+ bottom playback controls, and no horizontal page overflow.
+- Check focus mode, keyboard tab switching, overflow actions, drawer Escape and
+ focus restoration. Tab/focus changes must not replace audio or reset playback
+ and the current record's reading positions. Recording-active warnings and the
+ return-to-recorder action remain available.
+- Compare create, regenerate and revise dialogs with the pre-layout baseline:
+ all fields, settings/request tabs, Provider/model linkage, token estimates,
+ budget errors and submit/cancel states must remain unchanged. Assert complete
+ generation payloads, including the revise source-version ID.
+- Regress generation progress/cancel, missing/externally modified Markdown,
+ file relinking, stale responses, and on-demand request/response inspection.
+ Retain all existing playback, speaker, export and transcription tests.
+- Verify generation dialogs and the details drawer close on backdrop clicks,
+ but not inside clicks, cancelled pointers, secondary buttons, or text-selection
+ drags ending outside. Submission blocks close/cancel/backdrop dismissal. Check
+ drawer focus restoration and JSON keyboard expansion after the Lucide icon
+ adaptation. At both viewport sizes, scroll the complete form and confirm
+ header/footer remain visible and scrollbars never cover the shell's corners.
+- Visual fixtures must be synthetic and must not call real model services.
+ Browser viewport checks do not replace real Windows/WebView2 scaling and
+ audio-device acceptance.
+
## Documentation Verification
Documentation-only changes do not require rebuilding the application unless
diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs
index 9be0608..952b2c6 100644
--- a/src-tauri/src/controller.rs
+++ b/src-tauri/src/controller.rs
@@ -2927,30 +2927,59 @@ fn tray_controls(state: RecordingState) -> TrayControls {
}
fn status_icon(state: RecordingState, capture_prompt_pending: bool) -> Image<'static> {
- let color = if capture_prompt_pending {
- [218, 145, 45, 255]
- } else {
- match state {
- RecordingState::Recording | RecordingState::Preparing | RecordingState::Finalizing => {
- [202, 69, 69, 255]
- }
- RecordingState::Paused => [198, 145, 62, 255],
- RecordingState::Interrupted | RecordingState::Error => [164, 61, 61, 255],
- _ => [58, 128, 116, 255],
- }
+ // Compile-time decoding reuses the installer's icon family without a runtime
+ // image decoder or filesystem access. Keep the full-resolution app mark intact.
+ let base = tauri::include_image!("icons/32x32.png");
+ let Some(color) = tray_badge_color(state, capture_prompt_pending) else {
+ return base;
};
- let mut pixels = vec![0u8; 16 * 16 * 4];
- for y in 1..15 {
- for x in 1..15 {
- let dx = x as i32 - 7;
- let dy = y as i32 - 7;
- if dx * dx + dy * dy <= 42 {
- let offset = (y * 16 + x) * 4;
- pixels[offset..offset + 4].copy_from_slice(&color);
+ let (width, height) = (base.width(), base.height());
+ let mut pixels = base.rgba().to_vec();
+ let radius = width.min(height) as f32 * 0.1875;
+ let center_x = width as f32 - radius - 1.0;
+ let center_y = height as f32 - radius - 1.0;
+ // A light separator keeps the small badge readable over the app mark and
+ // both taskbar themes. Source-over coverage anti-aliases the circle edges.
+ for (radius, color) in [(radius, [255, 255, 255]), (radius * 0.75, color)] {
+ for y in 0..height {
+ for x in 0..width {
+ let distance = ((x as f32 + 0.5 - center_x).powi(2)
+ + (y as f32 + 0.5 - center_y).powi(2))
+ .sqrt();
+ let coverage = (radius + 0.5 - distance).clamp(0.0, 1.0);
+ if coverage == 0.0 {
+ continue;
+ }
+ let offset = ((y * width + x) * 4) as usize;
+ let base_alpha = pixels[offset + 3] as f32 / 255.0;
+ let alpha = coverage + base_alpha * (1.0 - coverage);
+ for channel in 0..3 {
+ pixels[offset + channel] = ((color[channel] as f32 * coverage
+ + pixels[offset + channel] as f32 * base_alpha * (1.0 - coverage))
+ / alpha)
+ .round() as u8;
+ }
+ pixels[offset + 3] = (alpha * 255.0).round() as u8;
}
}
}
- Image::new_owned(pixels, 16, 16)
+ Image::new_owned(pixels, width, height)
+}
+
+fn tray_badge_color(state: RecordingState, capture_prompt_pending: bool) -> Option<[u8; 3]> {
+ // Native counterparts of --color-status-{recording,paused,warning-accent}
+ // in design-tokens.css. Pending capture decisions retain highest priority.
+ if capture_prompt_pending {
+ return Some([217, 154, 66]);
+ }
+ match state {
+ RecordingState::Recording | RecordingState::Preparing | RecordingState::Finalizing => {
+ Some([200, 79, 69])
+ }
+ RecordingState::Paused => Some([189, 139, 53]),
+ RecordingState::Interrupted | RecordingState::Error => Some([217, 154, 66]),
+ RecordingState::Idle | RecordingState::Completed | RecordingState::Recovering => None,
+ }
}
pub fn run_app() {
@@ -3316,6 +3345,90 @@ mod transcript_export_tests {
mod tray_tests {
use super::*;
+ #[test]
+ fn idle_tray_uses_unmodified_packaged_app_icon() {
+ let base = tauri::include_image!("icons/32x32.png");
+ for state in [
+ RecordingState::Idle,
+ RecordingState::Completed,
+ RecordingState::Recovering,
+ ] {
+ let icon = status_icon(state, false);
+ assert_eq!((icon.width(), icon.height()), (32, 32));
+ assert_eq!(icon.rgba(), base.rgba());
+ }
+ }
+
+ #[test]
+ fn recording_badge_changes_only_the_bottom_right_corner() {
+ let base = tauri::include_image!("icons/32x32.png");
+ let recording = status_icon(RecordingState::Recording, false);
+ let mut changed = 0;
+ for (index, (before, after)) in base
+ .rgba()
+ .chunks_exact(4)
+ .zip(recording.rgba().chunks_exact(4))
+ .enumerate()
+ {
+ if before != after {
+ let (x, y) = (index % 32, index / 32);
+ assert!(x >= 18 && y >= 18, "badge must not replace the app mark");
+ changed += 1;
+ }
+ }
+ assert!(changed > 0 && changed < 32 * 32 / 4);
+ let center = (25 * 32 + 25) * 4;
+ assert_eq!(&recording.rgba()[center..center + 4], &[200, 79, 69, 255]);
+ assert!(
+ recording
+ .rgba()
+ .chunks_exact(4)
+ .any(|pixel| pixel == [255, 255, 255, 255])
+ );
+ assert!(
+ recording
+ .rgba()
+ .chunks_exact(4)
+ .any(|pixel| pixel[3] > 0 && pixel[3] < 255)
+ );
+ // Every new state is composed from the base, never from a previous badge.
+ assert_eq!(status_icon(RecordingState::Idle, false).rgba(), base.rgba());
+ }
+
+ #[test]
+ fn tray_badges_preserve_busy_pause_and_capture_warning_states() {
+ for state in [
+ RecordingState::Preparing,
+ RecordingState::Recording,
+ RecordingState::Finalizing,
+ ] {
+ assert_eq!(tray_badge_color(state, false), Some([200, 79, 69]));
+ }
+ assert_eq!(
+ tray_badge_color(RecordingState::Paused, false),
+ Some([189, 139, 53])
+ );
+ for state in [RecordingState::Interrupted, RecordingState::Error] {
+ assert_eq!(tray_badge_color(state, false), Some([217, 154, 66]));
+ }
+ for state in [
+ RecordingState::Idle,
+ RecordingState::Preparing,
+ RecordingState::Recording,
+ RecordingState::Paused,
+ RecordingState::Finalizing,
+ RecordingState::Completed,
+ RecordingState::Interrupted,
+ RecordingState::Error,
+ RecordingState::Recovering,
+ ] {
+ assert_eq!(tray_badge_color(state, true), Some([217, 154, 66]));
+ let icon = status_icon(state, true);
+ let center = (25 * 32 + 25) * 4;
+ assert_eq!(&icon.rgba()[center..center + 4], &[217, 154, 66, 255]);
+ }
+ }
+
#[test]
fn capture_prompt_height_is_bounded_and_rejects_invalid_values() {
assert_eq!(
diff --git a/src/App.test.tsx b/src/App.test.tsx
index a60bd01..48d7207 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,6 +1,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
+import appIconUrl from "../src-tauri/icons/128x128.png";
import { api } from "./api";
import type {
AudioImportBatchSnapshot,
@@ -292,6 +293,27 @@ const snapshot = (state: RecordingSnapshot["state"]): RecordingSnapshot => ({
});
describe("Nota UI states", () => {
+ it("uses the packaged application icon for the sidebar brand", async () => {
+ render(