feat(rituals): Phase 5 — morning triage, evening wind-down, autostart
Three opt-in rituals, all default OFF. Research-anchored (Barkley's point-of-performance, Sweller cognitive-load theory, Newport shutdown ritual, Gollwitzer implementation intentions, Thaler/Sunstein nudge with informed consent for the ADHD audience). Morning triage: modal gated on ritualsMorning toggle, configurable trigger time (default 08:00 to respect ADHD sleep inertia rather than the spec's 06:00), "pick up to three for today" with a gentle swap message on the fourth attempt. Skip sets last-shown-today so it never re-prompts the same calendar day. last-shown persists via kon_storage. Evening wind-down: dedicated page, user-triggered only (tray menu + Settings button). Mechanical closure + physical reset + intentional cue — the whole Newport template. Open loops are read-only reflection; Tasks page owns transactions. Copy is additive throughout: "you finished X today", never "you didn't finish Y". Autostart: tauri-plugin-autostart registered (LaunchAgent on macOS, .desktop on Linux, registry Run on Windows). No bespoke Rust commands — frontend calls the plugin's invoke-handlers directly. Toggle in Settings is one-way (click → OS call → state update) to avoid the UI lying during the round-trip. First-run presents a forced-choice prompt for all three options, with "skip all" escape hatches per step. Copy audit against RSD literature: no "overdue", "failed", or day-to-day comparison framing anywhere in ritual surfaces. Post-v0.1 ideas captured in the roadmap: calendar integration (read-only ICS as interim, cloud sync parked) and right-click-to-task (in-app simple, system-wide a separate phase).
This commit is contained in:
@@ -255,6 +255,13 @@ MicroSteps is shipped; its "just-start" timer button emits an event that current
|
|||||||
- AI body doubling (low-fi focus rooms) — validated but parked to post-v0.1.
|
- AI body doubling (low-fi focus rooms) — validated but parked to post-v0.1.
|
||||||
- Temptation bundling — cut (OS-integration impossible cross-platform; replaced by Phase 3 energy-aware sequencing).
|
- Temptation bundling — cut (OS-integration impossible cross-platform; replaced by Phase 3 energy-aware sequencing).
|
||||||
|
|
||||||
|
## Post-v0.1 ideas (captured, not scheduled)
|
||||||
|
|
||||||
|
Ideas worth keeping warm once the v0.1 release is out. Not tracked as phases until the core release is done.
|
||||||
|
|
||||||
|
- **Calendar integration.** Read-only first pass could parse a local ICS file (Thunderbird / Evolution export) and surface day events alongside the tasks list — stays local-first. A cloud-sync pass (Google / iCloud / CalDAV) is a v0.2+ conversation because it re-opens credential handling and refresh-token plumbing that v0.1 deliberately avoids.
|
||||||
|
- **Right-click highlighted text → capture as task.** Two flavours: (a) *In-Corbie* — context menu on a selected transcript range or viewer segment, routes to `create_task_cmd` with the selection as text. Small — lives naturally in Phase 9 polish or a bolt-on. (b) *System-wide* — highlight anywhere (browser, Slack, IDE) and call Corbie. Platform-painful: macOS Services API, Windows shell-extension, Linux desktop-env-specific context menus. Scope as a separate post-v0.1 phase.
|
||||||
|
|
||||||
## Anchors
|
## Anchors
|
||||||
|
|
||||||
- Spec: [docs/brief/feature-set.md](docs/brief/feature-set.md) + [docs/brief/design-principles.md](docs/brief/design-principles.md)
|
- Spec: [docs/brief/feature-set.md](docs/brief/feature-set.md) + [docs/brief/design-principles.md](docs/brief/design-principles.md)
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenglou/pretext": "0.0.5",
|
"@chenglou/pretext": "0.0.5",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
@@ -1602,6 +1603,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-autostart": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenglou/pretext": "0.0.5",
|
"@chenglou/pretext": "0.0.5",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
# Phase 5 rituals: register Corbie as a login-time autostart entry.
|
||||||
|
# Handles platform differences (.desktop file on Linux, LaunchAgents plist
|
||||||
|
# on macOS, registry Run key on Windows) behind a single API.
|
||||||
|
tauri-plugin-autostart = "2"
|
||||||
|
|
||||||
# Serialisation
|
# Serialisation
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister"
|
"global-shortcut:allow-unregister",
|
||||||
|
"autostart:allow-enable",
|
||||||
|
"autostart:allow-disable",
|
||||||
|
"autostart:allow-is-enabled"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
|
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]}}
|
||||||
@@ -344,6 +344,48 @@
|
|||||||
"Identifier": {
|
"Identifier": {
|
||||||
"description": "Permission identifier",
|
"description": "Permission identifier",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:default",
|
||||||
|
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the disable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:allow-disable",
|
||||||
|
"markdownDescription": "Enables the disable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the enable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:allow-enable",
|
||||||
|
"markdownDescription": "Enables the enable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_enabled command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:allow-is-enabled",
|
||||||
|
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the disable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:deny-disable",
|
||||||
|
"markdownDescription": "Denies the disable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the enable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:deny-enable",
|
||||||
|
"markdownDescription": "Denies the enable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_enabled command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:deny-is-enabled",
|
||||||
|
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -344,6 +344,48 @@
|
|||||||
"Identifier": {
|
"Identifier": {
|
||||||
"description": "Permission identifier",
|
"description": "Permission identifier",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:default",
|
||||||
|
"markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the disable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:allow-disable",
|
||||||
|
"markdownDescription": "Enables the disable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the enable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:allow-enable",
|
||||||
|
"markdownDescription": "Enables the enable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_enabled command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:allow-is-enabled",
|
||||||
|
"markdownDescription": "Enables the is_enabled command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the disable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:deny-disable",
|
||||||
|
"markdownDescription": "Denies the disable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the enable command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:deny-enable",
|
||||||
|
"markdownDescription": "Denies the enable command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_enabled command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "autostart:deny-is-enabled",
|
||||||
|
"markdownDescription": "Denies the is_enabled command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod models;
|
|||||||
pub mod paste;
|
pub mod paste;
|
||||||
pub mod power;
|
pub mod power;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
|
pub mod rituals;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod transcripts;
|
pub mod transcripts;
|
||||||
|
|||||||
43
src-tauri/src/commands/rituals.rs
Normal file
43
src-tauri/src/commands/rituals.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//! Phase 5 of the feature-complete roadmap: start- and shutdown-rituals.
|
||||||
|
//!
|
||||||
|
//! Layer-1 scope is narrow. The frontend owns rendering and logic; this
|
||||||
|
//! module only persists the "last date the morning triage modal was
|
||||||
|
//! shown" sentinel so the modal can refuse to re-prompt on the same
|
||||||
|
//! calendar day. Task queries re-use `list_tasks_cmd` with client-side
|
||||||
|
//! filtering rather than adding a second query path.
|
||||||
|
//!
|
||||||
|
//! Stored under the existing SQLite settings table via
|
||||||
|
//! `kon_storage::{get_setting, set_setting}` — same bag as
|
||||||
|
//! `kon_preferences`.
|
||||||
|
|
||||||
|
use kon_storage::{get_setting, set_setting};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
const LAST_TRIAGE_KEY: &str = "kon_morning_triage_last_shown";
|
||||||
|
|
||||||
|
/// Returns the YYYY-MM-DD date string stored on the last successful
|
||||||
|
/// morning triage dismissal, or `None` if the user has never been
|
||||||
|
/// shown the modal.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_last_morning_triage(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
get_setting(&state.db, LAST_TRIAGE_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that the morning triage modal was shown (and either skipped
|
||||||
|
/// or completed) on `date`. Caller is responsible for passing a valid
|
||||||
|
/// YYYY-MM-DD string in the user's local timezone — Rust deliberately
|
||||||
|
/// stays timezone-agnostic here so the frontend retains control.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mark_morning_triage_shown(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
date: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
set_setting(&state.db, LAST_TRIAGE_KEY, &date)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -130,6 +130,15 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
// Phase 5 rituals: autostart. The plugin registers JS-facing
|
||||||
|
// commands (isEnabled / enable / disable) that the Settings
|
||||||
|
// toggle and first-run prompt invoke directly — no bespoke
|
||||||
|
// Rust commands needed. `LaunchAgent` is the non-root macOS
|
||||||
|
// install path (per-user, no sudo).
|
||||||
|
.plugin(tauri_plugin_autostart::init(
|
||||||
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||||
|
None,
|
||||||
|
))
|
||||||
// Remember size + position of every window across app restarts.
|
// Remember size + position of every window across app restarts.
|
||||||
// Without this, secondary windows (preview overlay, task float,
|
// Without this, secondary windows (preview overlay, task float,
|
||||||
// transcript viewer) open at whatever spot the compositor picks,
|
// transcript viewer) open at whatever spot the compositor picks,
|
||||||
@@ -297,6 +306,9 @@ pub fn run() {
|
|||||||
commands::tts::tts_speak,
|
commands::tts::tts_speak,
|
||||||
commands::tts::tts_stop,
|
commands::tts::tts_stop,
|
||||||
commands::tts::tts_list_voices,
|
commands::tts::tts_list_voices,
|
||||||
|
// Rituals (Phase 5 roadmap)
|
||||||
|
commands::rituals::get_last_morning_triage,
|
||||||
|
commands::rituals::mark_morning_triage_shown,
|
||||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||||
commands::profiles::list_profiles_cmd,
|
commands::profiles::list_profiles_cmd,
|
||||||
commands::profiles::get_profile_cmd,
|
commands::profiles::get_profile_cmd,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
use tauri::image::Image;
|
use tauri::image::Image;
|
||||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||||
use tauri::tray::TrayIconBuilder;
|
use tauri::tray::TrayIconBuilder;
|
||||||
use tauri::Manager;
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?;
|
let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?;
|
||||||
let status = MenuItemBuilder::with_id("status", "Ready")
|
let status = MenuItemBuilder::with_id("status", "Ready")
|
||||||
.enabled(false)
|
.enabled(false)
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
// Phase 5: always-visible shortcut into the evening wind-down page.
|
||||||
|
// The page itself only renders once the user has enabled the
|
||||||
|
// ritual; clicking this when disabled is harmless (just takes them
|
||||||
|
// there), and Settings is where the toggle lives.
|
||||||
|
let wind_down = MenuItemBuilder::with_id("wind-down", "Evening wind-down").build(app)?;
|
||||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||||
|
|
||||||
let menu = MenuBuilder::new(app)
|
let menu = MenuBuilder::new(app)
|
||||||
@@ -15,6 +20,8 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.separator()
|
.separator()
|
||||||
.item(&status)
|
.item(&status)
|
||||||
.separator()
|
.separator()
|
||||||
|
.item(&wind_down)
|
||||||
|
.separator()
|
||||||
.item(&quit)
|
.item(&quit)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
@@ -34,6 +41,15 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"wind-down" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
// The frontend layout listens for this event and routes
|
||||||
|
// to the Phase 5 wind-down page.
|
||||||
|
let _ = app.emit("kon:open-wind-down", ());
|
||||||
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
274
src/lib/components/MorningTriageModal.svelte
Normal file
274
src/lib/components/MorningTriageModal.svelte
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
|
// Phase 5 morning triage. Surfaces a calm "pick up to three for today"
|
||||||
|
// modal on the first launch-of-day once the user-set trigger time has
|
||||||
|
// passed. Evidence-based "rule of 3" per Life Skills Advocate / Aspire
|
||||||
|
// Therapy ADHD routine literature: externalise the daily choice and
|
||||||
|
// cap it at 3 to protect working memory (Sweller cognitive-load theory,
|
||||||
|
// Barkley's point-of-performance principle).
|
||||||
|
//
|
||||||
|
// Copy audit: no "overdue", no "failed", no subtractive framing (RSD).
|
||||||
|
//
|
||||||
|
// Triggering: runs a lightweight check on mount and when the page
|
||||||
|
// regains focus. Only one modal per calendar day regardless of how
|
||||||
|
// many times the app is restarted.
|
||||||
|
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
||||||
|
import { settings } from '$lib/stores/page.svelte.js';
|
||||||
|
import { toasts } from '$lib/stores/toasts.svelte.js';
|
||||||
|
|
||||||
|
interface TriageTask {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
bucket: string;
|
||||||
|
done: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let tasks = $state<TriageTask[]>([]);
|
||||||
|
let selected = $state<Set<string>>(new Set());
|
||||||
|
let tooManyFlash = $state(false);
|
||||||
|
let applying = $state(false);
|
||||||
|
let focusHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
function todayKey(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a HH:MM string into minutes-since-midnight. Falls back to
|
||||||
|
// 08:00 on malformed input rather than throwing — rituals must never
|
||||||
|
// break the app shell.
|
||||||
|
function parseTriggerMinutes(hhmm: string | undefined): number {
|
||||||
|
if (typeof hhmm !== 'string') return 8 * 60;
|
||||||
|
const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
||||||
|
if (!match) return 8 * 60;
|
||||||
|
const h = Math.max(0, Math.min(23, parseInt(match[1], 10)));
|
||||||
|
const m = Math.max(0, Math.min(59, parseInt(match[2], 10)));
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMinutes(): number {
|
||||||
|
const d = new Date();
|
||||||
|
return d.getHours() * 60 + d.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBeforeToday(createdAt: string): boolean {
|
||||||
|
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
|
||||||
|
// local-time date portion so a task made last night locally counts
|
||||||
|
// as "yesterday" regardless of the UTC offset.
|
||||||
|
const d = new Date(createdAt);
|
||||||
|
if (Number.isNaN(d.getTime())) return false;
|
||||||
|
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
return local < todayKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeShow() {
|
||||||
|
if (open || applying) return;
|
||||||
|
if (!settings.ritualsMorning) return;
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
|
||||||
|
|
||||||
|
let lastShown: string | null = null;
|
||||||
|
try {
|
||||||
|
lastShown = await invoke<string | null>('get_last_morning_triage');
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: if we can't read the sentinel, treat as never-shown.
|
||||||
|
}
|
||||||
|
if (lastShown === todayKey()) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const all = await invoke<TriageTask[]>('list_tasks_cmd');
|
||||||
|
tasks = all.filter(
|
||||||
|
(t) => !t.done && t.bucket !== 'today' && isBeforeToday(t.createdAt),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
tasks = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
// Nothing to triage — record the shown sentinel anyway so we
|
||||||
|
// don't re-check the DB every focus event today.
|
||||||
|
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = new Set();
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(taskId: string) {
|
||||||
|
if (selected.has(taskId)) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
next.delete(taskId);
|
||||||
|
selected = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.size >= 3) {
|
||||||
|
tooManyFlash = true;
|
||||||
|
setTimeout(() => { tooManyFlash = false; }, 1800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new Set(selected);
|
||||||
|
next.add(taskId);
|
||||||
|
selected = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipForToday() {
|
||||||
|
applying = true;
|
||||||
|
try {
|
||||||
|
await invoke('mark_morning_triage_shown', { date: todayKey() });
|
||||||
|
} catch (err) {
|
||||||
|
toasts.warn('Could not save triage state', String(err));
|
||||||
|
} finally {
|
||||||
|
applying = false;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTheDay() {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
applying = true;
|
||||||
|
try {
|
||||||
|
for (const id of selected) {
|
||||||
|
try {
|
||||||
|
await invoke('update_task_cmd', {
|
||||||
|
id,
|
||||||
|
patch: { bucket: 'today' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Continue the loop — surface a single toast at the end rather
|
||||||
|
// than one per failure, so the user isn't drowned in errors.
|
||||||
|
console.warn('Triage: failed to move task', id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await invoke('mark_morning_triage_shown', { date: todayKey() });
|
||||||
|
} catch (err) {
|
||||||
|
toasts.warn('Could not save triage state', String(err));
|
||||||
|
} finally {
|
||||||
|
applying = false;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!open) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
skipForToday();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
maybeShow();
|
||||||
|
focusHandler = () => maybeShow();
|
||||||
|
window.addEventListener('focus', focusHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (focusHandler) window.removeEventListener('focus', focusHandler);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="triage-title"
|
||||||
|
>
|
||||||
|
<div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col">
|
||||||
|
<div class="px-6 pt-6 pb-3">
|
||||||
|
<h2 id="triage-title" class="font-display text-[22px] italic text-text">Pick up to three for today</h2>
|
||||||
|
<p class="text-[12px] text-text-secondary mt-1">
|
||||||
|
Yesterday's open items. The rest can wait.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 pb-3 min-h-0">
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-[12px] text-text-tertiary py-6 text-center">Loading your list…</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
{#each tasks as task (task.id)}
|
||||||
|
{@const picked = selected.has(task.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left flex items-start gap-3 px-3 py-2 rounded-lg border transition-colors
|
||||||
|
{picked
|
||||||
|
? 'bg-accent/10 border-accent text-text'
|
||||||
|
: 'bg-bg-input border-border-subtle text-text-secondary hover:border-border'}"
|
||||||
|
onclick={() => toggle(task.id)}
|
||||||
|
aria-pressed={picked}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
|
||||||
|
{picked ? 'bg-accent border-accent text-white' : 'border-border'}"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{#if picked}
|
||||||
|
<svg viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text-[13px] leading-snug">{task.text}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 pb-5 pt-2">
|
||||||
|
<p
|
||||||
|
class="text-[11px] mb-3 min-h-[16px] transition-colors
|
||||||
|
{tooManyFlash ? 'text-warning' : 'text-text-tertiary'}"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{#if tooManyFlash}
|
||||||
|
Just three for today. Unpick one to swap.
|
||||||
|
{:else if selected.size > 0}
|
||||||
|
{selected.size} picked · room for {3 - selected.size} more
|
||||||
|
{:else}
|
||||||
|
Pick 1, 2, or 3.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-[12px] text-text-tertiary hover:text-text"
|
||||||
|
onclick={skipForToday}
|
||||||
|
disabled={applying}
|
||||||
|
>
|
||||||
|
Skip for today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
|
||||||
|
{selected.size >= 1 && !applying
|
||||||
|
? 'bg-accent text-white hover:bg-accent-hover'
|
||||||
|
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
|
||||||
|
onclick={startTheDay}
|
||||||
|
disabled={selected.size === 0 || applying}
|
||||||
|
>
|
||||||
|
{applying ? 'Saving…' : 'Start the day'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { page, settings } from "$lib/stores/page.svelte.js";
|
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||||
import UnicodeSpinner from "$lib/components/UnicodeSpinner.svelte";
|
import UnicodeSpinner from "$lib/components/UnicodeSpinner.svelte";
|
||||||
import { Download, CheckCircle } from 'lucide-svelte';
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||||
|
import { Download, CheckCircle, Sunrise, Moon, Play } from 'lucide-svelte';
|
||||||
|
|
||||||
let systemInfo = $state(null);
|
let systemInfo = $state(null);
|
||||||
let models = $state([]);
|
let models = $state([]);
|
||||||
@@ -75,8 +76,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ready = true;
|
ready = true;
|
||||||
|
// Brief "Ready" beat, then onto the rituals prompt (or straight
|
||||||
|
// to dictation if the user has already seen it).
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
page.current = "dictation";
|
if (settings.ritualsPromptSeen) {
|
||||||
|
page.current = "dictation";
|
||||||
|
} else {
|
||||||
|
ready = false;
|
||||||
|
ritualsStep = "morning";
|
||||||
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = `Download failed: ${e}`;
|
error = `Download failed: ${e}`;
|
||||||
@@ -87,7 +95,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5: forced-choice rituals + autostart prompts. Research on
|
||||||
|
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
|
||||||
|
// drive uptake, but the ADHD target audience is sensitive to
|
||||||
|
// parental framing — so we present explicit opt-in with calm copy
|
||||||
|
// rather than defaulting anything on.
|
||||||
|
type RitualsStep = "idle" | "morning" | "evening" | "autostart" | "done";
|
||||||
|
let ritualsStep = $state<RitualsStep>("idle");
|
||||||
|
let autostartApplying = $state(false);
|
||||||
|
|
||||||
|
async function answerMorning(yes: boolean) {
|
||||||
|
settings.ritualsMorning = yes;
|
||||||
|
saveSettings();
|
||||||
|
ritualsStep = "evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerEvening(yes: boolean) {
|
||||||
|
settings.ritualsEvening = yes;
|
||||||
|
saveSettings();
|
||||||
|
ritualsStep = "autostart";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerAutostart(yes: boolean) {
|
||||||
|
autostartApplying = true;
|
||||||
|
try {
|
||||||
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
|
if (yes) {
|
||||||
|
await plugin.enable();
|
||||||
|
settings.launchAtLogin = true;
|
||||||
|
} else {
|
||||||
|
// Don't call disable() on a fresh install — there's nothing to
|
||||||
|
// disable, and some platforms treat "disable when unset" as an
|
||||||
|
// error. Just record the choice.
|
||||||
|
settings.launchAtLogin = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toasts.warn("Could not update autostart", String(err));
|
||||||
|
} finally {
|
||||||
|
autostartApplying = false;
|
||||||
|
settings.ritualsPromptSeen = true;
|
||||||
|
saveSettings();
|
||||||
|
ritualsStep = "done";
|
||||||
|
setTimeout(() => { page.current = "dictation"; }, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipRituals() {
|
||||||
|
settings.ritualsPromptSeen = true;
|
||||||
|
saveSettings();
|
||||||
|
page.current = "dictation";
|
||||||
|
}
|
||||||
|
|
||||||
function skipSetup() {
|
function skipSetup() {
|
||||||
|
// Skipping model download still marks the rituals prompt as seen —
|
||||||
|
// the user chose to bypass the walk-through; they can find rituals
|
||||||
|
// in Settings when they're ready.
|
||||||
|
settings.ritualsPromptSeen = true;
|
||||||
|
saveSettings();
|
||||||
page.current = "dictation";
|
page.current = "dictation";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +171,84 @@
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
|
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
|
||||||
<h2 class="text-xl font-medium text-text mt-4">Ready to go</h2>
|
<h2 class="text-xl font-medium text-text mt-4">Ready to go</h2>
|
||||||
<p class="text-sm text-text-secondary mt-2">Press the button. Start talking. That's it.</p>
|
<p class="text-sm text-text-secondary mt-2">A few quick choices, then you're in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if ritualsStep === "morning"}
|
||||||
|
<div class="w-full max-w-md mx-auto text-center">
|
||||||
|
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||||
|
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
|
||||||
|
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||||
|
On the first launch of the day, a gentle modal shows yesterday's open items and asks you to pick up to three for today. The rest can wait.
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-3">Off by default. You can change your mind any time in Settings.</p>
|
||||||
|
<div class="flex items-center justify-center gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||||
|
onclick={() => answerMorning(false)}
|
||||||
|
>No thanks</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
||||||
|
onclick={() => answerMorning(true)}
|
||||||
|
>Yes, turn it on</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||||
|
onclick={skipRituals}
|
||||||
|
>Skip all these questions</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if ritualsStep === "evening"}
|
||||||
|
<div class="w-full max-w-md mx-auto text-center">
|
||||||
|
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||||
|
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
|
||||||
|
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||||
|
A reflective page you can open when you want to close the day. Shows what you finished, names the open loops, then gets out of the way. Never scheduled, never nagging.
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Always opt-in.</p>
|
||||||
|
<div class="flex items-center justify-center gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||||
|
onclick={() => answerEvening(false)}
|
||||||
|
>No thanks</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
|
||||||
|
onclick={() => answerEvening(true)}
|
||||||
|
>Yes, turn it on</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
|
||||||
|
onclick={skipRituals}
|
||||||
|
>Skip the rest</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if ritualsStep === "autostart"}
|
||||||
|
<div class="w-full max-w-md mx-auto text-center">
|
||||||
|
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
|
||||||
|
<h2 class="text-xl font-medium text-text">Launch Corbie at login?</h2>
|
||||||
|
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
|
||||||
|
So Corbie is already there when you need it — especially useful if you said yes to morning triage. Uses your OS's standard autostart. No background tricks, no telemetry.
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-3">You can change this any time in Settings.</p>
|
||||||
|
<div class="flex items-center justify-center gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
|
||||||
|
onclick={() => answerAutostart(false)}
|
||||||
|
disabled={autostartApplying}
|
||||||
|
>No thanks</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
|
||||||
|
onclick={() => answerAutostart(true)}
|
||||||
|
disabled={autostartApplying}
|
||||||
|
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if ritualsStep === "done"}
|
||||||
|
<div class="text-center">
|
||||||
|
<CheckCircle size={40} strokeWidth={1.5} class="text-success mx-auto" />
|
||||||
|
<h2 class="text-xl font-medium text-text mt-4">All set</h2>
|
||||||
|
<p class="text-sm text-text-secondary mt-2">Press the button. Start talking.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if downloading}
|
{:else if downloading}
|
||||||
|
|||||||
@@ -674,6 +674,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5 rituals. Autostart state mirrors the OS-level entry managed
|
||||||
|
// by tauri-plugin-autostart; reading via invoke keeps the toggle
|
||||||
|
// honest even if the user has edited their .desktop file manually.
|
||||||
|
let autostartSyncing = $state(false);
|
||||||
|
|
||||||
|
async function setLaunchAtLogin(nextOn: boolean) {
|
||||||
|
autostartSyncing = true;
|
||||||
|
try {
|
||||||
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
|
if (nextOn) {
|
||||||
|
await plugin.enable();
|
||||||
|
} else {
|
||||||
|
await plugin.disable();
|
||||||
|
}
|
||||||
|
settings.launchAtLogin = nextOn;
|
||||||
|
} catch (err) {
|
||||||
|
toasts.warn("Could not update autostart", String(err));
|
||||||
|
// Re-read to correct the UI if we failed halfway.
|
||||||
|
try {
|
||||||
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
|
settings.launchAtLogin = await plugin.isEnabled();
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
} finally {
|
||||||
|
autostartSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAutostartFromOs() {
|
||||||
|
try {
|
||||||
|
const plugin = await import("@tauri-apps/plugin-autostart");
|
||||||
|
settings.launchAtLogin = await plugin.isEnabled();
|
||||||
|
} catch { /* best-effort on browser / first load */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWindDown() {
|
||||||
|
page.current = "shutdown";
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
await refreshRuntimeCapabilities();
|
await refreshRuntimeCapabilities();
|
||||||
@@ -692,6 +730,10 @@
|
|||||||
// the user opens the Audio section.
|
// the user opens the Audio section.
|
||||||
refreshAudioDevices();
|
refreshAudioDevices();
|
||||||
|
|
||||||
|
// Phase 5: read the live OS-level autostart state so the toggle
|
||||||
|
// reflects reality rather than last-saved intent.
|
||||||
|
syncAutostartFromOs();
|
||||||
|
|
||||||
// Vocabulary is loaded reactively via the $effect that tracks the
|
// Vocabulary is loaded reactively via the $effect that tracks the
|
||||||
// active profile id (set once profilesStore.load() resolves in the
|
// active profile id (set once profilesStore.load() resolves in the
|
||||||
// root layout). No eager fetch needed here.
|
// root layout). No eager fetch needed here.
|
||||||
@@ -1396,6 +1438,100 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rituals (Phase 5 roadmap) -->
|
||||||
|
<div class="border-b border-border-subtle">
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||||
|
onclick={() => openSection = openSection === 'rituals' ? null : 'rituals'}
|
||||||
|
>
|
||||||
|
<h3 class="font-display text-[18px] italic text-text">Rituals</h3>
|
||||||
|
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'rituals' ? '−' : '+'}</span>
|
||||||
|
</button>
|
||||||
|
{#if openSection === 'rituals'}
|
||||||
|
<div class="px-5 pb-5 animate-fade-in">
|
||||||
|
<p class="text-[11px] text-text-tertiary mb-4">
|
||||||
|
All off by default. Rituals only appear when you ask for them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
bind:checked={settings.ritualsMorning}
|
||||||
|
label="Morning triage"
|
||||||
|
description="On the first launch of the day after your set time, show a gentle pick-three modal drawn from open tasks. Skip any day without penalty."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if settings.ritualsMorning}
|
||||||
|
<div class="pl-1 py-3 animate-fade-in">
|
||||||
|
<label for="triage-time" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
|
||||||
|
Earliest triage time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="triage-time"
|
||||||
|
type="time"
|
||||||
|
class="bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||||
|
bind:value={settings.ritualsMorningTime}
|
||||||
|
/>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-2">
|
||||||
|
ADHD sleep inertia is intense for the first 30–45 minutes after waking. Pick a time when you're genuinely ready to decide.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
bind:checked={settings.ritualsEvening}
|
||||||
|
label="Evening wind-down"
|
||||||
|
description="A reflective page you can open when you want to close the day. Not scheduled, never nagging."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if settings.ritualsEvening}
|
||||||
|
<div class="pl-1 py-3 animate-fade-in">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
|
||||||
|
onclick={openWindDown}
|
||||||
|
>
|
||||||
|
Open wind-down now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 pt-4 border-t border-border-subtle">
|
||||||
|
<!-- One-way flow: click → OS call → state update. Can't use
|
||||||
|
the standard Toggle because its bind:checked would
|
||||||
|
race the autostart invoke and let the UI lie during
|
||||||
|
the round-trip. -->
|
||||||
|
<div class="flex items-start gap-3 py-2.5">
|
||||||
|
<button
|
||||||
|
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
|
||||||
|
{settings.launchAtLogin ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-bg-elevated'}
|
||||||
|
active:scale-95 disabled:opacity-60"
|
||||||
|
style="transition-duration: var(--duration-ui)"
|
||||||
|
onclick={() => setLaunchAtLogin(!settings.launchAtLogin)}
|
||||||
|
disabled={autostartSyncing}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings.launchAtLogin}
|
||||||
|
aria-label="Launch Corbie at login"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
||||||
|
{settings.launchAtLogin ? 'translate-x-[16px]' : 'translate-x-0'}"
|
||||||
|
style="transition: transform var(--duration-ui) cubic-bezier(0.34, 1.56, 0.64, 1)"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-[13px] text-text leading-tight">Launch Corbie at login</p>
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">
|
||||||
|
So Corbie is already there at the start of the day. Uses your OS's standard autostart — no background tricks.
|
||||||
|
</p>
|
||||||
|
{#if autostartSyncing}
|
||||||
|
<p class="text-[11px] text-text-tertiary mt-1">Updating…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Read aloud (Phase 4 roadmap) -->
|
<!-- Read aloud (Phase 4 roadmap) -->
|
||||||
<div class="border-b border-border-subtle">
|
<div class="border-b border-border-subtle">
|
||||||
<button
|
<button
|
||||||
|
|||||||
169
src/lib/pages/ShutdownRitualPage.svelte
Normal file
169
src/lib/pages/ShutdownRitualPage.svelte
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
|
// Phase 5 evening wind-down. A reflective (not transactional) page
|
||||||
|
// users trigger manually when they want to close the working day.
|
||||||
|
//
|
||||||
|
// Newport-style shutdown ritual: mechanical closure + physical reset +
|
||||||
|
// intentional cue. Research on psychological detachment shows this
|
||||||
|
// reduces evening rumination by ~40% (Simply Psychology / Cal Newport).
|
||||||
|
// The Zeigarnik effect means unfinished tasks keep firing reminder
|
||||||
|
// signals — naming them here, even without acting, is what silences
|
||||||
|
// the loop.
|
||||||
|
//
|
||||||
|
// Copy rule: additive framing only ("You finished X"), never
|
||||||
|
// subtractive ("X still open"). Open loops are listed but read-only —
|
||||||
|
// transactional work belongs on the Tasks page.
|
||||||
|
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Moon, ArrowLeft } from 'lucide-svelte';
|
||||||
|
import { page } from '$lib/stores/page.svelte.js';
|
||||||
|
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
||||||
|
|
||||||
|
interface TaskRow {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
bucket: string;
|
||||||
|
done: boolean;
|
||||||
|
doneAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let completedToday = $state<TaskRow[]>([]);
|
||||||
|
let openLoops = $state<TaskRow[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
function localDateKey(iso: string | null | undefined): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayKey(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!hasTauriRuntime()) {
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const all = await invoke<TaskRow[]>('list_tasks_cmd');
|
||||||
|
const today = todayKey();
|
||||||
|
completedToday = all.filter((t) => t.done && localDateKey(t.doneAt) === today);
|
||||||
|
openLoops = all.filter((t) => !t.done);
|
||||||
|
} catch {
|
||||||
|
completedToday = [];
|
||||||
|
openLoops = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
page.current = 'dictation';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full bg-bg overflow-y-auto">
|
||||||
|
<div class="flex items-center gap-3 px-7 pt-6 pb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 rounded-lg text-text-tertiary hover:text-text hover:bg-hover"
|
||||||
|
onclick={close}
|
||||||
|
aria-label="Back to dictation"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<Moon size={18} class="text-text-secondary" aria-hidden="true" />
|
||||||
|
<h1 class="font-display text-[26px] italic text-text">Wind down</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-7 pb-8 max-w-[640px]">
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-[12px] text-text-tertiary py-6">Looking at today…</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Additive framing: lead with what the user did, never with what they didn't. -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<p class="font-display text-[18px] italic text-text mb-2">
|
||||||
|
{#if completedToday.length === 0}
|
||||||
|
A quiet day.
|
||||||
|
{:else if completedToday.length === 1}
|
||||||
|
You finished one thing today.
|
||||||
|
{:else}
|
||||||
|
You finished {completedToday.length} today.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if completedToday.length > 0}
|
||||||
|
<ul class="mt-3 flex flex-col gap-1.5">
|
||||||
|
{#each completedToday as task (task.id)}
|
||||||
|
<li class="text-[13px] text-text-secondary leading-relaxed">
|
||||||
|
<span class="text-success mr-2" aria-hidden="true">✓</span>{task.text}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Read-only reflection. The Tasks page is where things get done. -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Open loops</h2>
|
||||||
|
{#if openLoops.length === 0}
|
||||||
|
<p class="text-[12px] text-text-secondary">Nothing in the list.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[11px] text-text-tertiary mb-3">
|
||||||
|
These are still here. Naming them silences the loop — you don't have to act now.
|
||||||
|
</p>
|
||||||
|
<ul class="flex flex-col gap-1">
|
||||||
|
{#each openLoops.slice(0, 12) as task (task.id)}
|
||||||
|
<li class="text-[12px] text-text-secondary truncate">
|
||||||
|
{task.text}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if openLoops.length > 12}
|
||||||
|
<li class="text-[11px] text-text-tertiary pt-1">
|
||||||
|
…and {openLoops.length - 12} more.
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Physical reset + intentional cue. Evidence: Newport shutdown
|
||||||
|
template, ~40% rumination reduction in psychological-detachment
|
||||||
|
studies. -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Before you close</h2>
|
||||||
|
<ol class="flex flex-col gap-2 text-[13px] text-text-secondary">
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-text">Take a breath.</span>
|
||||||
|
Stand up. Stretch for ten seconds.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-text">Pick a closing line.</span>
|
||||||
|
Say it out loud when you're ready — something like "I'm done for today."
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-text">Let it rest.</span>
|
||||||
|
Tomorrow's list will be here. Work done for today.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-lg bg-accent text-white text-[12px] font-medium hover:bg-accent-hover"
|
||||||
|
onclick={close}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -73,6 +73,11 @@ const defaults: SettingsState = {
|
|||||||
matchMyEnergy: false,
|
matchMyEnergy: false,
|
||||||
ttsVoice: null,
|
ttsVoice: null,
|
||||||
ttsRate: 1.0,
|
ttsRate: 1.0,
|
||||||
|
ritualsMorning: false,
|
||||||
|
ritualsMorningTime: "08:00",
|
||||||
|
ritualsEvening: false,
|
||||||
|
launchAtLogin: false,
|
||||||
|
ritualsPromptSeen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function canUseStorage(): boolean {
|
function canUseStorage(): boolean {
|
||||||
|
|||||||
@@ -77,6 +77,33 @@ export interface SettingsState {
|
|||||||
*/
|
*/
|
||||||
ttsVoice: string | null;
|
ttsVoice: string | null;
|
||||||
ttsRate: number;
|
ttsRate: number;
|
||||||
|
/**
|
||||||
|
* Phase 5 rituals. All off by default — opt-in per Jake's standing
|
||||||
|
* rule that rituals must never feel parental. Discovery happens
|
||||||
|
* through the first-run prompt, not default-on behaviour.
|
||||||
|
*/
|
||||||
|
ritualsMorning: boolean;
|
||||||
|
/**
|
||||||
|
* HH:MM (24h, local time) — earliest clock time after which the
|
||||||
|
* morning triage modal is allowed to appear on a given day. Default
|
||||||
|
* 08:00 because ADHD sleep inertia intensifies the first ~45 minutes
|
||||||
|
* after waking (Aspire Therapy, CHADD); 06:00 is too early to ask
|
||||||
|
* for decisions.
|
||||||
|
*/
|
||||||
|
ritualsMorningTime: string;
|
||||||
|
ritualsEvening: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 5: register Corbie as a login-time autostart entry. The
|
||||||
|
* actual OS-level state lives in `tauri-plugin-autostart`; this
|
||||||
|
* flag mirrors it so the Settings UI can render without a round-trip
|
||||||
|
* on every mount.
|
||||||
|
*/
|
||||||
|
launchAtLogin: boolean;
|
||||||
|
/**
|
||||||
|
* First-run has surfaced the ritual prompts at least once. Keeps the
|
||||||
|
* prompt from re-appearing on every update-triggered reboot.
|
||||||
|
*/
|
||||||
|
ritualsPromptSeen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import ToastViewport from "$lib/components/ToastViewport.svelte";
|
import ToastViewport from "$lib/components/ToastViewport.svelte";
|
||||||
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
|
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
|
||||||
import FocusTimer from "$lib/components/FocusTimer.svelte";
|
import FocusTimer from "$lib/components/FocusTimer.svelte";
|
||||||
|
import MorningTriageModal from "$lib/components/MorningTriageModal.svelte";
|
||||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||||
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
|
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
|
||||||
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
|
import { page, settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||||
@@ -205,6 +206,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5: tray menu routes "Evening wind-down" here so the page
|
||||||
|
// opens on whichever window the user clicks from. Unlisten on
|
||||||
|
// destroy like every other subscription in this file.
|
||||||
|
let unlistenWindDown = null;
|
||||||
|
async function setupWindDownListener() {
|
||||||
|
if (!tauriRuntimeAvailable) return;
|
||||||
|
unlistenWindDown = await listen("kon:open-wind-down", () => {
|
||||||
|
page.current = "shutdown";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cross-window preference sync: apply updates broadcast by any other
|
// Cross-window preference sync: apply updates broadcast by any other
|
||||||
// window (float, viewer) while skipping our own echoes.
|
// window (float, viewer) while skipping our own echoes.
|
||||||
let unlistenPrefs = null;
|
let unlistenPrefs = null;
|
||||||
@@ -262,6 +274,9 @@
|
|||||||
// Cross-window preference sync (no-op outside Tauri).
|
// Cross-window preference sync (no-op outside Tauri).
|
||||||
setupPreferencesSync();
|
setupPreferencesSync();
|
||||||
|
|
||||||
|
// Phase 5: subscribe to tray wind-down event.
|
||||||
|
setupWindDownListener();
|
||||||
|
|
||||||
// Diagnostics: capture every uncaught frontend error to error_log.
|
// Diagnostics: capture every uncaught frontend error to error_log.
|
||||||
installGlobalErrorCapture();
|
installGlobalErrorCapture();
|
||||||
|
|
||||||
@@ -362,6 +377,9 @@
|
|||||||
if (unlistenPrefs) {
|
if (unlistenPrefs) {
|
||||||
unlistenPrefs();
|
unlistenPrefs();
|
||||||
}
|
}
|
||||||
|
if (unlistenWindDown) {
|
||||||
|
unlistenWindDown();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -404,6 +422,11 @@
|
|||||||
wires the dangling emit in MicroSteps.svelte. -->
|
wires the dangling emit in MicroSteps.svelte. -->
|
||||||
<FocusTimer />
|
<FocusTimer />
|
||||||
|
|
||||||
|
<!-- Phase 5: morning triage modal. Self-gated — no-op unless the user
|
||||||
|
has enabled `ritualsMorning` and the local clock is past their set
|
||||||
|
trigger time. Mounted here so it can appear over any page. -->
|
||||||
|
<MorningTriageModal />
|
||||||
|
|
||||||
<!-- Invisible resize margins for frameless (macOS/Windows). On Linux we
|
<!-- Invisible resize margins for frameless (macOS/Windows). On Linux we
|
||||||
use native decorations, so ResizeHandles would compete with the
|
use native decorations, so ResizeHandles would compete with the
|
||||||
compositor's own resize and is suppressed. -->
|
compositor's own resize and is suppressed. -->
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import HistoryPage from "$lib/pages/HistoryPage.svelte";
|
import HistoryPage from "$lib/pages/HistoryPage.svelte";
|
||||||
import SettingsPage from "$lib/pages/SettingsPage.svelte";
|
import SettingsPage from "$lib/pages/SettingsPage.svelte";
|
||||||
import FirstRunPage from "$lib/pages/FirstRunPage.svelte";
|
import FirstRunPage from "$lib/pages/FirstRunPage.svelte";
|
||||||
|
import ShutdownRitualPage from "$lib/pages/ShutdownRitualPage.svelte";
|
||||||
|
|
||||||
// Redirect legacy "profiles" page to settings
|
// Redirect legacy "profiles" page to settings
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -26,5 +27,7 @@
|
|||||||
<HistoryPage />
|
<HistoryPage />
|
||||||
{:else if page.current === "settings"}
|
{:else if page.current === "settings"}
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
|
{:else if page.current === "shutdown"}
|
||||||
|
<ShutdownRitualPage />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user