diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 2249b3f..25d0c66 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -524,6 +524,125 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> { Ok(()) } +// --- Implementation intentions --- + +#[allow(clippy::too_many_arguments)] +pub async fn insert_implementation_rule( + pool: &SqlitePool, + id: &str, + enabled: bool, + trigger_kind: &str, + trigger_value: &str, + actions_json: &str, + last_fired_key: Option<&str>, +) -> Result { + sqlx::query( + "INSERT INTO implementation_rules ( + id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key + ) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(enabled as i64) + .bind(trigger_kind) + .bind(trigger_value) + .bind(actions_json) + .bind(last_fired_key) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert implementation rule failed: {e}")))?; + + get_implementation_rule(pool, id).await?.ok_or_else(|| { + KonError::StorageError(format!( + "insert_implementation_rule: rule {id} not found after insert" + )) + }) +} + +pub async fn list_implementation_rules(pool: &SqlitePool) -> Result> { + let rows = sqlx::query( + "SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \ + created_at, updated_at \ + FROM implementation_rules ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List implementation rules failed: {e}")))?; + + Ok(rows.into_iter().map(implementation_rule_row_from).collect()) +} + +pub async fn get_implementation_rule( + pool: &SqlitePool, + id: &str, +) -> Result> { + let row = sqlx::query( + "SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \ + created_at, updated_at \ + FROM implementation_rules WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get implementation rule failed: {e}")))?; + + Ok(row.map(implementation_rule_row_from)) +} + +pub async fn set_implementation_rule_enabled( + pool: &SqlitePool, + id: &str, + enabled: bool, +) -> Result { + sqlx::query( + "UPDATE implementation_rules + SET enabled = ?, updated_at = datetime('now') + WHERE id = ?", + ) + .bind(enabled as i64) + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Set implementation rule enabled failed: {e}")))?; + + get_implementation_rule(pool, id).await?.ok_or_else(|| { + KonError::StorageError(format!( + "set_implementation_rule_enabled: rule {id} not found after update" + )) + }) +} + +pub async fn mark_implementation_rule_fired( + pool: &SqlitePool, + id: &str, + last_fired_key: &str, +) -> Result { + sqlx::query( + "UPDATE implementation_rules + SET last_fired_key = ?, updated_at = datetime('now') + WHERE id = ?", + ) + .bind(last_fired_key) + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Mark implementation rule fired failed: {e}")))?; + + get_implementation_rule(pool, id).await?.ok_or_else(|| { + KonError::StorageError(format!( + "mark_implementation_rule_fired: rule {id} not found after update" + )) + }) +} + +pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("DELETE FROM implementation_rules WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Delete implementation rule failed: {e}")))?; + Ok(()) +} + // --- Settings CRUD --- pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> { @@ -612,6 +731,18 @@ pub struct TaskRow { pub energy: Option, } +#[derive(Debug, Clone)] +pub struct ImplementationRuleRow { + pub id: String, + pub enabled: bool, + pub trigger_kind: String, + pub trigger_value: String, + pub actions_json: String, + pub last_fired_key: Option, + pub created_at: String, + pub updated_at: String, +} + fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { TranscriptRow { id: r.get("id"), @@ -676,6 +807,19 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow { } } +fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow { + ImplementationRuleRow { + id: r.get("id"), + enabled: r.get::("enabled") != 0, + trigger_kind: r.get("trigger_kind"), + trigger_value: r.get("trigger_value"), + actions_json: r.get("actions_json"), + last_fired_key: r.get("last_fired_key"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + } +} + // --- Profile CRUD (Phase 2 Task 11) --- // // Profiles partition the per-user transcription dictionary so that a single @@ -1318,6 +1462,46 @@ mod tests { assert!(b.done, "sibling must be untouched"); } + #[tokio::test] + async fn implementation_rule_crud_roundtrip() { + let pool = test_pool().await; + + let rule = insert_implementation_rule( + &pool, + "rule-1", + true, + "time_of_day", + "09:00", + r#"[{"kind":"speak_line","text":"time to plan the day"}]"#, + Some("2026-04-24"), + ) + .await + .unwrap(); + + assert_eq!(rule.id, "rule-1"); + assert!(rule.enabled); + assert_eq!(rule.trigger_kind, "time_of_day"); + assert_eq!(rule.trigger_value, "09:00"); + assert_eq!(rule.last_fired_key.as_deref(), Some("2026-04-24")); + + let rules = list_implementation_rules(&pool).await.unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].actions_json, rule.actions_json); + + let disabled = set_implementation_rule_enabled(&pool, "rule-1", false) + .await + .unwrap(); + assert!(!disabled.enabled); + + let fired = mark_implementation_rule_fired(&pool, "rule-1", "2026-04-25") + .await + .unwrap(); + assert_eq!(fired.last_fired_key.as_deref(), Some("2026-04-25")); + + delete_implementation_rule(&pool, "rule-1").await.unwrap(); + assert!(list_implementation_rules(&pool).await.unwrap().is_empty()); + } + #[tokio::test] async fn update_transcript_meta_happy_path() { // Task 2.5 — insert a transcript, update starred=true, read it back. diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 5ed569a..b02f73a 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,13 +8,15 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; pub use database::{ add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts, - create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript, - get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task, - insert_transcript, list_feedback_examples, list_profile_terms, list_profiles, - list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged, - log_error, record_feedback, search_transcripts, set_setting, set_task_energy, uncomplete_task, - update_profile, update_task, update_transcript, update_transcript_meta, ErrorLogRow, - FeedbackRow, FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow, - RecordFeedbackParams, TaskRow, TranscriptRow, + create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task, + delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id, + get_transcript, init, insert_implementation_rule, insert_subtask, insert_task, + insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, + list_profiles, list_recent_errors, list_subtasks, list_tasks, list_transcripts, + list_transcripts_paged, log_error, mark_implementation_rule_fired, record_feedback, + search_transcripts, set_implementation_rule_enabled, set_setting, set_task_energy, + uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta, + ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams, + ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 6715c7d..3c96520 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -400,6 +400,36 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ON tasks(energy, created_at DESC); "#, ), + ( + 12, + "implementation intentions: if-then automation rules", + r#" + -- Phase 7 of the feature-complete roadmap. Rules are local-only, + -- user-authored implementation intentions: "if this happens, then + -- do this small thing". Execution stays in the frontend event bus; + -- SQLite owns the durable definition and the once-per-day marker + -- for time-of-day rules. + CREATE TABLE implementation_rules ( + id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1 + CHECK (enabled IN (0, 1)), + trigger_kind TEXT NOT NULL + CHECK (trigger_kind IN ( + 'time_of_day', + 'task_completed', + 'morning_triage_finished' + )), + trigger_value TEXT NOT NULL DEFAULT '', + actions_json TEXT NOT NULL DEFAULT '[]', + last_fired_key TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX idx_implementation_rules_enabled_trigger + ON implementation_rules(enabled, trigger_kind); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -549,7 +579,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 11); + assert_eq!(count, 12); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -568,7 +598,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 11); + assert_eq!(count, 12); } #[tokio::test] @@ -596,6 +626,44 @@ mod tests { } } + #[tokio::test] + async fn migration_implementation_rules_adds_rule_table() { + let pool = fk_test_pool().await; + run_migrations(&pool).await.expect("migrate"); + + let info = sqlx::query("PRAGMA table_info(implementation_rules)") + .fetch_all(&pool) + .await + .unwrap(); + let names: Vec = info.iter().map(|r| r.get::("name")).collect(); + for col in [ + "id", + "enabled", + "trigger_kind", + "trigger_value", + "actions_json", + "last_fired_key", + "created_at", + "updated_at", + ] { + assert!( + names.contains(&col.to_string()), + "implementation_rules must have {col}; got {names:?}" + ); + } + + let rejected = sqlx::query( + "INSERT INTO implementation_rules (id, trigger_kind, actions_json) + VALUES ('bad', 'calendar_event', '[]')", + ) + .execute(&pool) + .await; + assert!( + rejected.is_err(), + "trigger_kind CHECK constraint must reject unknown triggers" + ); + } + #[tokio::test] async fn migration_transcripts_meta_adds_columns() { // Task 2.5 — verify starred / manual_tags / template / language / diff --git a/src-tauri/src/commands/intentions.rs b/src-tauri/src/commands/intentions.rs new file mode 100644 index 0000000..64ce69b --- /dev/null +++ b/src-tauri/src/commands/intentions.rs @@ -0,0 +1,308 @@ +//! Phase 7 implementation intentions: small if-then automation rules. +//! +//! The frontend owns scheduling and execution because the v1 triggers are +//! app-local events (timer/task/ritual) plus local clock checks. Rust owns +//! durable rule storage, validation, and main-window-only command access. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use kon_storage::{ + delete_implementation_rule as db_delete_rule, get_task_by_id, + insert_implementation_rule as db_insert_rule, list_implementation_rules as db_list_rules, + mark_implementation_rule_fired as db_mark_rule_fired, + set_implementation_rule_enabled as db_set_rule_enabled, ImplementationRuleRow, +}; + +use crate::commands::security::ensure_main_window; +use crate::AppState; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleTriggerKind { + TimeOfDay, + TaskCompleted, + MorningTriageFinished, +} + +impl RuleTriggerKind { + fn as_str(&self) -> &'static str { + match self { + RuleTriggerKind::TimeOfDay => "time_of_day", + RuleTriggerKind::TaskCompleted => "task_completed", + RuleTriggerKind::MorningTriageFinished => "morning_triage_finished", + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SurfaceTarget { + Inbox, + Today, + Tasks, + Task, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuleAction { + Surface { + target: SurfaceTarget, + #[serde(default)] + #[serde(rename = "taskId")] + task_id: Option, + #[serde(default)] + label: Option, + }, + StartTimer { + seconds: u32, + #[serde(default)] + #[serde(rename = "taskId")] + task_id: Option, + #[serde(default)] + label: Option, + }, + SpeakLine { + text: String, + }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateImplementationRuleRequest { + pub trigger_kind: RuleTriggerKind, + pub trigger_value: String, + pub actions: Vec, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub last_fired_key: Option, +} + +fn default_enabled() -> bool { + true +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImplementationRuleDto { + pub id: String, + pub enabled: bool, + pub trigger_kind: RuleTriggerKind, + pub trigger_value: String, + pub actions: Vec, + pub last_fired_key: Option, + pub created_at: String, + pub updated_at: String, +} + +impl TryFrom for ImplementationRuleDto { + type Error = String; + + fn try_from(row: ImplementationRuleRow) -> Result { + let trigger_kind = match row.trigger_kind.as_str() { + "time_of_day" => RuleTriggerKind::TimeOfDay, + "task_completed" => RuleTriggerKind::TaskCompleted, + "morning_triage_finished" => RuleTriggerKind::MorningTriageFinished, + other => return Err(format!("unknown rule trigger kind: {other}")), + }; + let actions = serde_json::from_str::>(&row.actions_json) + .map_err(|e| format!("invalid rule actions JSON for {}: {e}", row.id))?; + Ok(Self { + id: row.id, + enabled: row.enabled, + trigger_kind, + trigger_value: row.trigger_value, + actions, + last_fired_key: row.last_fired_key, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } +} + +fn validate_hhmm(value: &str) -> Result<(), String> { + let (h, m) = value + .split_once(':') + .ok_or_else(|| "time rules need HH:MM".to_string())?; + if h.len() != 2 || m.len() != 2 { + return Err("time rules need HH:MM".into()); + } + let hour: u8 = h + .parse() + .map_err(|_| "time hour must be 00-23".to_string())?; + let minute: u8 = m + .parse() + .map_err(|_| "time minute must be 00-59".to_string())?; + if hour > 23 || minute > 59 { + return Err("time must be between 00:00 and 23:59".into()); + } + Ok(()) +} + +async fn validate_actions( + state: &tauri::State<'_, AppState>, + actions: &[RuleAction], +) -> Result<(), String> { + if actions.is_empty() { + return Err("add at least one rule action".into()); + } + for action in actions { + match action { + RuleAction::Surface { + target, + task_id, + label: _, + } => { + if matches!(target, SurfaceTarget::Task) { + let task_id = task_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| "surface-task actions need a task".to_string())?; + let exists = get_task_by_id(&state.db, task_id) + .await + .map_err(|e| e.to_string())? + .is_some(); + if !exists { + return Err(format!("task {task_id} does not exist")); + } + } + } + RuleAction::StartTimer { seconds, .. } => { + if *seconds != 300 { + return Err("v1 rule timers are fixed at 5 minutes".into()); + } + } + RuleAction::SpeakLine { text } => { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err("speak actions need a line to read".into()); + } + if trimmed.chars().count() > 240 { + return Err("speak lines must be 240 characters or fewer".into()); + } + } + } + } + Ok(()) +} + +async fn validate_request( + state: &tauri::State<'_, AppState>, + request: &CreateImplementationRuleRequest, +) -> Result<(), String> { + match request.trigger_kind { + RuleTriggerKind::TimeOfDay => validate_hhmm(request.trigger_value.trim())?, + RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => { + if !request.trigger_value.trim().is_empty() { + return Err("this trigger does not take an extra value".into()); + } + } + } + validate_actions(state, &request.actions).await +} + +#[tauri::command] +pub async fn list_implementation_rules( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, +) -> Result, String> { + ensure_main_window(&window)?; + db_list_rules(&state.db) + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(ImplementationRuleDto::try_from) + .collect() +} + +#[tauri::command] +pub async fn create_implementation_rule( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + request: CreateImplementationRuleRequest, +) -> Result { + ensure_main_window(&window)?; + validate_request(&state, &request).await?; + + let id = Uuid::new_v4().to_string(); + let actions_json = serde_json::to_string(&request.actions) + .map_err(|e| format!("serialise rule actions failed: {e}"))?; + let trigger_value = match request.trigger_kind { + RuleTriggerKind::TimeOfDay => request.trigger_value.trim().to_string(), + RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => String::new(), + }; + + db_insert_rule( + &state.db, + &id, + request.enabled, + request.trigger_kind.as_str(), + &trigger_value, + &actions_json, + request.last_fired_key.as_deref(), + ) + .await + .map_err(|e| e.to_string()) + .and_then(ImplementationRuleDto::try_from) +} + +#[tauri::command] +pub async fn set_implementation_rule_enabled( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + id: String, + enabled: bool, +) -> Result { + ensure_main_window(&window)?; + db_set_rule_enabled(&state.db, &id, enabled) + .await + .map_err(|e| e.to_string()) + .and_then(ImplementationRuleDto::try_from) +} + +#[tauri::command] +pub async fn mark_implementation_rule_fired( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + id: String, + fired_key: String, +) -> Result { + ensure_main_window(&window)?; + let key = fired_key.trim(); + if key.is_empty() { + return Err("fired_key is required".into()); + } + db_mark_rule_fired(&state.db, &id, key) + .await + .map_err(|e| e.to_string()) + .and_then(ImplementationRuleDto::try_from) +} + +#[tauri::command] +pub async fn delete_implementation_rule( + window: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + ensure_main_window(&window)?; + db_delete_rule(&state.db, &id) + .await + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::validate_hhmm; + + #[test] + fn hhmm_validation_accepts_and_rejects_expected_shapes() { + assert!(validate_hhmm("09:00").is_ok()); + assert!(validate_hhmm("23:59").is_ok()); + assert!(validate_hhmm("9:00").is_err()); + assert!(validate_hhmm("24:00").is_err()); + assert!(validate_hhmm("08:60").is_err()); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9ac6daa..17e719b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod diagnostics; pub mod feedback; pub mod hardware; pub mod hotkey; +pub mod intentions; pub mod live; pub mod llm; pub mod meeting; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ebdd08..2a47fe8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -329,6 +329,12 @@ pub fn run() { commands::rituals::mark_morning_triage_shown, // Nudges (Phase 6 roadmap) commands::nudges::deliver_nudge, + // Implementation intentions (Phase 7 roadmap) + commands::intentions::list_implementation_rules, + commands::intentions::create_implementation_rule, + commands::intentions::set_implementation_rule_enabled, + commands::intentions::mark_implementation_rule_fired, + commands::intentions::delete_implementation_rule, // Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12 commands::profiles::list_profiles_cmd, commands::profiles::get_profile_cmd, diff --git a/src/lib/components/ImplementationRulesEditor.svelte b/src/lib/components/ImplementationRulesEditor.svelte new file mode 100644 index 0000000..65ac8a2 --- /dev/null +++ b/src/lib/components/ImplementationRulesEditor.svelte @@ -0,0 +1,266 @@ + + +
+
+

+ Build simple if-then rules. They respect Mute for now in Nudges, and everything stays local. +

+ +
+
+

If

+
+ + {#if triggerKind === "time_of_day"} + + {/if} +
+ +

Then

+
+
+ + {#if surfaceEnabled} +
+ + {#if surfaceTarget === "task"} + + {/if} +
+ {/if} +
+ + + +
+ + +
+
+
+ + {#if error} +

{error}

+ {/if} + +
+ +
+
+
+ +
+

Saved rules

+ {#if loading} +

Loading…

+ {:else if implementationRules.length === 0} +

No rules yet.

+ {:else} +
+ {#each implementationRules as rule (rule.id)} +
+ +

+ {ruleSummary(rule)} +

+ +
+ {/each} +
+ {/if} +
+
diff --git a/src/lib/components/MorningTriageModal.svelte b/src/lib/components/MorningTriageModal.svelte index 82ca253..e437cb8 100644 --- a/src/lib/components/MorningTriageModal.svelte +++ b/src/lib/components/MorningTriageModal.svelte @@ -60,6 +60,13 @@ return d.getHours() * 60 + d.getMinutes(); } + function dispatchTriageFinished(mode: 'empty' | 'skipped' | 'picked') { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent('kon:morning-triage-finished', { + detail: { date: todayKey(), mode }, + })); + } + 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 @@ -100,6 +107,7 @@ // 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 {} + dispatchTriageFinished('empty'); return; } @@ -133,6 +141,7 @@ } finally { applying = false; open = false; + dispatchTriageFinished('skipped'); } } @@ -158,6 +167,7 @@ } finally { applying = false; open = false; + dispatchTriageFinished('picked'); } } diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index ac42565..9045605 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -8,6 +8,7 @@ import Toggle from "$lib/components/Toggle.svelte"; import SegmentedButton from "$lib/components/SegmentedButton.svelte"; import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte"; + import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte"; import ZonePicker from "$lib/components/ZonePicker.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; @@ -1571,6 +1572,22 @@ {/if} + +
+ + {#if openSection === 'rules'} +
+ +
+ {/if} +
+