This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/docs/superpowers/plans/2026-03-11-app-overhaul-p1-foundation.md
T
bvandeusen 6232c7c99a feat: app overhaul — Briefing-first navigation, Library, capture queue
Plans 1-3 implemented:

Plan 1 — Foundation
- Add google_fonts ^6.2.1
- lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces
  headings, GradientButton widget
- lib/providers/capture_work_queue_provider.dart: in-memory sequential
  work queue; CaptureWorkQueueNotifier drains one item at a time;
  captureResultProvider feeds snackbars to UI
- lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking
  _QuickCaptureBar with queue-based implementation (progress bar, badge)

Plan 2 — Navigation
- 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat)
- lib/screens/library/library_screen.dart: unified notes+tasks+projects list
  with filter pills, status sub-filter for tasks, live search, FAB
- lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard
  (status cycle), ProjectLibraryCard
- lib/screens/chat/conversations_tab_screen.dart: focused replacement for
  ConversationsListScreen
- Delete 5 dead screens: notes_list, tasks_list, project_list,
  conversations_list, quick_capture

Plan 3 — Briefing
- lib/data/models/briefing_conversation.dart
- lib/data/api/briefing_api.dart: getToday, getHistory, getMessages,
  triggerSlot
- lib/providers/briefing_provider.dart: BriefingNotifier with sendReply
  (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh
- lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble
  (ghost user bubbles, left-accent assistant bubbles)
- lib/widgets/briefing_digest_card.dart: expandable first-message card
- lib/screens/briefing/briefing_screen.dart: digest card, conversation list,
  streaming reply bar, refresh button, history overflow menu
- lib/screens/briefing/briefing_history_screen.dart: read-only past dates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:17:38 -04:00

24 KiB
Raw Blame History

Fabled App Overhaul — Plan 1: Foundation (Theme + Capture Queue)

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Apply the main app's slate-indigo palette and Fraunces typography to the Flutter app, and replace the blocking quick-capture input with a multi-item sequential work queue.

Architecture: A new lib/core/theme.dart defines both light and dark ThemeData with custom ColorScheme and Fraunces headings via google_fonts. The capture bar in app.dart delegates to a new CaptureWorkQueueNotifier (in-memory queue, drains sequentially) instead of blocking on each request. A _captureResultProvider carries per-item outcomes to the bar for snackbar display.

Tech Stack: Flutter/Dart, Riverpod, google_fonts package.


File Map

Create:

  • lib/core/theme.dart — light + dark ThemeData, GradientButton widget
  • lib/providers/capture_work_queue_provider.dart — in-memory work queue notifier + result provider

Modify:

  • pubspec.yaml — add google_fonts: ^6.2.1
  • lib/main.dart — import and use fabledTheme / fabledDarkTheme
  • lib/app.dart — update _QuickCaptureBar to use work queue

Chunk 1: Theme

Task 1: Add google_fonts dependency

Files:

  • Modify: pubspec.yaml

  • Step 1: Add dependency

In the dependencies: section, after flutter_markdown_plus, add:

  google_fonts: ^6.2.1
  • Step 2: Verify it resolves
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter pub get

Expected: exits 0, no version conflicts.

  • Step 3: Commit
git add pubspec.yaml pubspec.lock
git commit -m "feat: add google_fonts dependency"

Task 2: Create theme.dart

Files:

  • Create: lib/core/theme.dart

  • Step 1: Create lib/core/theme.dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

// ── Colour constants ──────────────────────────────────────────────────────────

const _darkBackground    = Color(0xFF111113);
const _darkSurface       = Color(0xFF18181C);
const _darkSurfaceVar    = Color(0xFF1E1E24);
const _darkPrimary       = Color(0xFF6366F1);
const _darkOnSurface     = Color(0xFFE8E8F0);
const _darkOnSurfaceVar  = Color(0xFF8888A8);
const _darkOutline       = Color(0xFF2E2E3A);

const _lightBackground   = Color(0xFFF4F4F8);
const _lightSurface      = Color(0xFFFFFFFF);
const _lightSurfaceVar   = Color(0xFFF0F0F5);
const _lightPrimary      = Color(0xFF4F46E5);
const _lightOnSurface    = Color(0xFF18181C);
const _lightOnSurfaceVar = Color(0xFF6B6B88);
const _lightOutline      = Color(0xFFD4D4E4);

// ── Typography ─────────────────────────────────────────────────────────────────

TextTheme _buildTextTheme(TextTheme base) {
  final fraunces = GoogleFonts.frauncesTextTheme(base);
  return base.copyWith(
    // Headings / titles use Fraunces
    headlineLarge:  fraunces.headlineLarge,
    headlineMedium: fraunces.headlineMedium,
    headlineSmall:  fraunces.headlineSmall,
    titleLarge:     fraunces.titleLarge,
    titleMedium:    fraunces.titleMedium,
    // Body / labels remain system default
  );
}

// ── Themes ─────────────────────────────────────────────────────────────────────

ThemeData fabledDarkTheme() {
  final cs = ColorScheme(
    brightness:          Brightness.dark,
    primary:             _darkPrimary,
    onPrimary:           Colors.white,
    primaryContainer:    const Color(0xFF3730A3),
    onPrimaryContainer:  _darkOnSurface,
    secondary:           _darkPrimary,
    onSecondary:         Colors.white,
    secondaryContainer:  _darkSurfaceVar,
    onSecondaryContainer: _darkOnSurface,
    tertiary:            _darkPrimary,
    onTertiary:          Colors.white,
    tertiaryContainer:   _darkSurfaceVar,
    onTertiaryContainer: _darkOnSurface,
    error:               const Color(0xFFEF4444),
    onError:             Colors.white,
    errorContainer:      const Color(0xFF7F1D1D),
    onErrorContainer:    const Color(0xFFFEE2E2),
    surface:             _darkSurface,
    onSurface:           _darkOnSurface,
    surfaceContainerHighest: _darkSurfaceVar,
    onSurfaceVariant:    _darkOnSurfaceVar,
    outline:             _darkOutline,
    outlineVariant:      _darkOutline,
    shadow:              Colors.black,
    scrim:               Colors.black,
    inverseSurface:      _darkOnSurface,
    onInverseSurface:    _darkSurface,
    inversePrimary:      _lightPrimary,
  );

  return ThemeData(
    useMaterial3:   true,
    colorScheme:    cs,
    scaffoldBackgroundColor: _darkBackground,
    textTheme: _buildTextTheme(ThemeData.dark().textTheme),
    cardTheme: CardTheme(
      color:         _darkSurface,
      elevation:     2,
      shadowColor:   Colors.black.withValues(alpha: 0.4),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
    ),
    navigationBarTheme: NavigationBarThemeData(
      backgroundColor: _darkSurface,
      indicatorColor:  _darkPrimary.withValues(alpha: 0.2),
    ),
    navigationRailTheme: NavigationRailThemeData(
      backgroundColor: _darkSurface,
      indicatorColor:  _darkPrimary.withValues(alpha: 0.2),
    ),
    inputDecorationTheme: InputDecorationTheme(
      filled:      true,
      fillColor:   _darkSurfaceVar,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(24),
        borderSide: BorderSide(color: _darkOutline),
      ),
      enabledBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(24),
        borderSide: BorderSide(color: _darkOutline),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(24),
        borderSide: BorderSide(color: _darkPrimary, width: 2),
      ),
    ),
    dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
    chipTheme: ChipThemeData(
      backgroundColor: _darkSurfaceVar,
      labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
      side: BorderSide(color: _darkOutline),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    ),
  );
}

ThemeData fabledLightTheme() {
  final cs = ColorScheme(
    brightness:          Brightness.light,
    primary:             _lightPrimary,
    onPrimary:           Colors.white,
    primaryContainer:    const Color(0xFFE0E0FF),
    onPrimaryContainer:  _lightOnSurface,
    secondary:           _lightPrimary,
    onSecondary:         Colors.white,
    secondaryContainer:  _lightSurfaceVar,
    onSecondaryContainer: _lightOnSurface,
    tertiary:            _lightPrimary,
    onTertiary:          Colors.white,
    tertiaryContainer:   _lightSurfaceVar,
    onTertiaryContainer: _lightOnSurface,
    error:               const Color(0xFFDC2626),
    onError:             Colors.white,
    errorContainer:      const Color(0xFFFEE2E2),
    onErrorContainer:    const Color(0xFF7F1D1D),
    surface:             _lightSurface,
    onSurface:           _lightOnSurface,
    surfaceContainerHighest: _lightSurfaceVar,
    onSurfaceVariant:    _lightOnSurfaceVar,
    outline:             _lightOutline,
    outlineVariant:      _lightOutline,
    shadow:              Colors.black,
    scrim:               Colors.black,
    inverseSurface:      _lightOnSurface,
    onInverseSurface:    _lightSurface,
    inversePrimary:      _darkPrimary,
  );

  return ThemeData(
    useMaterial3:   true,
    colorScheme:    cs,
    scaffoldBackgroundColor: _lightBackground,
    textTheme: _buildTextTheme(ThemeData.light().textTheme),
    cardTheme: CardTheme(
      color:         _lightSurface,
      elevation:     1,
      shadowColor:   Colors.black.withValues(alpha: 0.08),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
    ),
    navigationBarTheme: NavigationBarThemeData(
      backgroundColor: _lightSurface,
      indicatorColor:  _lightPrimary.withValues(alpha: 0.12),
    ),
    navigationRailTheme: NavigationRailThemeData(
      backgroundColor: _lightSurface,
      indicatorColor:  _lightPrimary.withValues(alpha: 0.12),
    ),
    inputDecorationTheme: InputDecorationTheme(
      filled:      true,
      fillColor:   _lightSurfaceVar,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(24),
        borderSide: BorderSide(color: _lightOutline),
      ),
      enabledBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(24),
        borderSide: BorderSide(color: _lightOutline),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(24),
        borderSide: BorderSide(color: _lightPrimary, width: 2),
      ),
    ),
    dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
    chipTheme: ChipThemeData(
      backgroundColor: _lightSurfaceVar,
      labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
      side: BorderSide(color: _lightOutline),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    ),
  );
}

// ── GradientButton ─────────────────────────────────────────────────────────────
// Use wherever the web app uses the indigo gradient button (send, primary actions).

class GradientButton extends StatelessWidget {
  final VoidCallback? onPressed;
  final Widget child;
  final EdgeInsetsGeometry padding;

  const GradientButton({
    super.key,
    required this.onPressed,
    required this.child,
    this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
  });

  @override
  Widget build(BuildContext context) {
    final disabled = onPressed == null;
    return AnimatedOpacity(
      opacity: disabled ? 0.45 : 1.0,
      duration: const Duration(milliseconds: 150),
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: disabled
              ? null
              : const LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
                ),
          color: disabled ? const Color(0xFF6366F1) : null,
          borderRadius: BorderRadius.circular(12),
          boxShadow: disabled
              ? null
              : [
                  BoxShadow(
                    color: const Color(0xFF6366F1).withValues(alpha: 0.35),
                    blurRadius: 8,
                    offset: const Offset(0, 3),
                  ),
                ],
        ),
        child: Material(
          color: Colors.transparent,
          child: InkWell(
            onTap: onPressed,
            borderRadius: BorderRadius.circular(12),
            child: Padding(padding: padding, child: child),
          ),
        ),
      ),
    );
  }
}
  • Step 2: Verify it compiles
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/core/theme.dart

Expected: no errors (warnings about deprecated APIs are OK).

  • Step 3: Commit
git add lib/core/theme.dart
git commit -m "feat: custom slate-indigo theme with Fraunces typography"

Task 3: Wire theme into the app

Files:

  • Modify: lib/main.dart (import theme)

  • Modify: lib/app.dart (FabledApp widget uses new themes)

  • Step 1: Read lib/app.dart lines 498523 (the FabledApp widget)

Locate the FabledApp.build() method. It currently has:

theme: ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
  useMaterial3: true,
),
darkTheme: ThemeData(
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.indigo,
    brightness: Brightness.dark,
  ),
  useMaterial3: true,
),
  • Step 2: Replace with custom themes

Add import at the top of lib/app.dart:

import 'core/theme.dart';

Replace the theme: and darkTheme: arguments:

theme: fabledLightTheme(),
darkTheme: fabledDarkTheme(),

Remove the now-unused import 'package:flutter/material.dart' reference to Colors.indigo (keep the material.dart import itself).

  • Step 3: Run the app and visually verify
flutter run --debug

Expected: app launches with dark slate-indigo background, indigo navigation bar, Fraunces headings visible on any screen that uses titleLarge or headlineMedium. No runtime errors.

  • Step 4: Commit
git add lib/app.dart
git commit -m "feat: wire fabledLightTheme/fabledDarkTheme into MaterialApp"

Chunk 2: Capture Work Queue

Task 4: Create CaptureWorkQueueNotifier

Files:

  • Create: lib/providers/capture_work_queue_provider.dart

This provider manages an in-memory FIFO queue of capture texts. A single async drain loop processes them sequentially. On success it publishes a result via captureResultProvider so the UI can show a snackbar. On NetworkException it falls through to the offline captureQueueProvider.

  • Step 1: Create lib/providers/capture_work_queue_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../core/exceptions.dart';
import '../data/api/quick_capture_api.dart';
import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';

/// Outcome of a single capture attempt — consumed by the UI for snackbars.
class CaptureResult {
  final String message;
  final bool isError;
  const CaptureResult(this.message, {this.isError = false});
}

/// The most recent capture result. UI watches this to show snackbars.
/// Reset to null by the notifier before each new item so listeners always fire.
final captureResultProvider = StateProvider<CaptureResult?>((_) => null);

/// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider =
    StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
  (ref) => CaptureWorkQueueNotifier(ref),
);

class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
  final Ref _ref;
  bool _running = false;

  CaptureWorkQueueNotifier(this._ref) : super([]);

  /// Add text to the queue and start the drain loop if not already running.
  void enqueue(String text) {
    state = [...state, text];
    _drain();
  }

  Future<void> _drain() async {
    if (_running) return;
    _running = true;
    try {
      while (state.isNotEmpty) {
        final text = state.first;
        // Signal "no result yet" so the same result value can re-trigger watch.
        _ref.read(captureResultProvider.notifier).state = null;
        try {
          final api = _ref.read(quickCaptureApiProvider);
          final result = await api.capture(text);

          // Dequeue on success.
          state = state.length > 1 ? state.sublist(1) : [];

          // Invalidate content providers so lists refresh.
          switch (result.type) {
            case 'note':
              _ref.invalidate(notesProvider);
            case 'task':
            case 'todo':
              _ref.invalidate(tasksProvider);
          }

          // Publish result for snackbar.
          final msg = result.message.isNotEmpty
              ? result.message
              : '${_typeLabel(result.type)} created: ${result.title}';
          _ref.read(captureResultProvider.notifier).state =
              CaptureResult(msg);
        } on NetworkException catch (_) {
          // Persist to offline queue and stop draining — still offline.
          await _ref.read(captureQueueProvider.notifier).enqueue(text);
          state = state.length > 1 ? state.sublist(1) : [];
          _ref.read(captureResultProvider.notifier).state = CaptureResult(
            "You're offline — capture saved and will retry automatically.",
            isError: false,
          );
          break;
        } on AppException catch (e) {
          state = state.length > 1 ? state.sublist(1) : [];
          _ref.read(captureResultProvider.notifier).state =
              CaptureResult(e.message, isError: true);
        } catch (_) {
          state = state.length > 1 ? state.sublist(1) : [];
          _ref.read(captureResultProvider.notifier).state =
              CaptureResult('Capture failed. Please try again.', isError: true);
        }
      }
    } finally {
      _running = false;
    }
  }

  String _typeLabel(String type) => switch (type) {
        'note' => 'Note',
        'task' => 'Task',
        'event' => 'Event',
        'todo' => 'To-do',
        _ => type,
      };
}
  • Step 2: Analyze for errors
flutter analyze lib/providers/capture_work_queue_provider.dart

Expected: no errors.

  • Step 3: Commit
git add lib/providers/capture_work_queue_provider.dart
git commit -m "feat: CaptureWorkQueueNotifier — sequential multi-item capture queue"

Task 5: Update _QuickCaptureBar to use the work queue

Files:

  • Modify: lib/app.dart_QuickCaptureBarState

The bar currently calls ref.read(quickCaptureApiProvider).capture(text) directly and sets _busy. Replace with: enqueue to work queue, watch queue depth for badge, watch captureResultProvider for snackbars, show progress bar when queue is non-empty.

  • Step 1: Add imports to lib/app.dart

Add at the top alongside existing imports:

import 'providers/capture_work_queue_provider.dart';
  • Step 2: Replace _QuickCaptureBarState completely

Replace the entire _QuickCaptureBarState class (from class _QuickCaptureBarState through its closing }) with:

class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
  final _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _submit() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    _controller.clear();
    setState(() {}); // clear suffix icon
    ref.read(captureWorkQueueProvider.notifier).enqueue(text);
  }

  Future<void> _drainOfflineQueue() async {
    if (!mounted) return;
    final queue = ref.read(captureQueueProvider);
    if (queue.isEmpty) return;
    final api = ref.read(quickCaptureApiProvider);
    for (final text in List<String>.from(queue)) {
      if (!mounted) break;
      try {
        final result = await api.capture(text);
        if (!mounted) break;
        await ref.read(captureQueueProvider.notifier).dequeue(text);
        switch (result.type) {
          case 'note':
            ref.invalidate(notesProvider);
          case 'task':
          case 'todo':
            ref.invalidate(tasksProvider);
            ref.invalidate(projectsProvider);
            ref.invalidate(projectMilestonesProvider);
        }
      } on NetworkException {
        break;
      } catch (_) {
        if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
      }
    }
  }

  String _hintForLocation(String location) {
    if (location.startsWith(Routes.tasks)) return 'Add a task…';
    if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
    return 'Capture a note…';
  }

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).matchedLocation;
    final offlineQueueCount = ref.watch(captureQueueProvider).length;
    final workQueue = ref.watch(captureWorkQueueProvider);
    final isWorking = workQueue.isNotEmpty;
    final totalPending = workQueue.length + offlineQueueCount;

    // Show snackbar when a result is published.
    ref.listen(captureResultProvider, (_, result) {
      if (result == null || !mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(result.message),
          behavior: SnackBarBehavior.floating,
        ),
      );
    });

    return SafeArea(
      bottom: false,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    textInputAction: TextInputAction.send,
                    onSubmitted: (_) => _submit(),
                    onChanged: (_) => setState(() {}),
                    decoration: InputDecoration(
                      hintText: _hintForLocation(location),
                      isDense: true,
                      contentPadding: const EdgeInsets.symmetric(
                          horizontal: 14, vertical: 10),
                      prefixIcon: totalPending > 0
                          ? Badge(
                              label: Text('$totalPending'),
                              child: const Icon(Icons.cloud_upload_outlined),
                            )
                          : isWorking
                              ? const Padding(
                                  padding: EdgeInsets.all(12),
                                  child: SizedBox(
                                    width: 16,
                                    height: 16,
                                    child: CircularProgressIndicator(
                                        strokeWidth: 2),
                                  ),
                                )
                              : const Icon(Icons.auto_awesome_outlined),
                      suffixIcon: _controller.text.trim().isNotEmpty
                          ? IconButton(
                              icon: const Icon(Icons.send),
                              onPressed: _submit,
                              tooltip: 'Capture',
                            )
                          : null,
                    ),
                  ),
                ),
                IconButton(
                  icon: const Icon(Icons.settings_outlined),
                  tooltip: 'Settings',
                  onPressed: () => context.push(Routes.settings),
                ),
              ],
            ),
          ),
          // Thin progress bar while the work queue is draining.
          if (isWorking)
            const LinearProgressIndicator(minHeight: 2)
          else
            const SizedBox(height: 2),
        ],
      ),
    );
  }
}
  • Step 3: Remove now-unused _busy field and old _submit/_send methods

They are fully replaced by the new class above. Verify there are no remaining references to _busy or the old _send method in _QuickCaptureBarState.

  • Step 4: Analyze
flutter analyze lib/app.dart

Expected: no errors. Fix any missing imports surfaced by the analyzer.

  • Step 5: Run and test manually
flutter run --debug

Test:

  1. Type a capture and submit — input clears immediately, progress bar appears briefly, snackbar shows on completion
  2. Type and submit 3 captures rapidly — all 3 appear in the queue badge, drain one by one, 3 snackbars appear in sequence
  3. The input field is never disabled during processing
  • Step 6: Commit
git add lib/app.dart
git commit -m "feat: multi-item capture work queue with sequential drain and progress indicator"

Verification Checklist

  • flutter analyze — zero errors
  • App launches with dark slate-indigo background on a device/emulator in dark mode
  • App launches with light theme when device is set to light mode
  • Fraunces font visible in screen titles (e.g. Notes AppBar title)
  • Capture bar: submit while processing → second item queues, badge shows count
  • Capture bar: submit 3 items → all drain sequentially, 3 snackbars
  • Offline: capture → "saved and will retry" snackbar, falls to offline queue