Closes the code-side v0.1 ship gate. All quality gates green: cargo fmt/clippy/test (~327 tests), npm check (0/0), vitest 13/13, scripts/dogfood-rebrand-drill.sh 8/8. Phase F — first-run onboarding promoted to v0.1 - FirstRunPage with skip-to-main + failure recovery + event recording - Six onboarding commands (record/list/has-completed + lumotia_events) - Storage migration v17 (onboarding_events + lumotia_events tables) UI hardening (in-scope items from v0.1-ui-hardening.md) - StatusPill + PostCaptureCard components, 21st preview entry - Sidebar recording-as-sacred-state (opacity + aria-disabled, reduced-motion) - Settings 6-section regroup + Help section + Activation log + Privacy toggle - Error-state copy sweep (DictationPage + SettingsPage, plain-language) - Global :focus-visible rule, textarea outlines restored - Ctrl+K / Ctrl+, / Escape bindings in +layout LLM resilience - rule_based_extract_tasks (regex-free imperative-verb extractor) + extract_tasks_with_fallback wrapper — task extraction never returns zero - tokio::time::timeout(120s) wraps cleanup/tags/tasks commands Release artefacts - LICENSE (canonical AGPL-3.0), CHANGELOG (Keep-a-Changelog format) - v0.1-release-notes, privacy-and-ai-use, install-warnings, tester-onboarding-kit, tester-acceptance-runbook, code-signing-setup, apple-silicon-rb08-runbook, virtual-audio-setup, v0.1-contrast-audit - Workspace versioning + AGPL spdx; npm exact-pin (10 ranges removed) - AppImage SHA-256 sidecar in build.yml - README v0.1 section + Reporting-issues; canonical repo slug Closure pass — items moved from human-required to code-complete - KI-02 Linux idle inhibit: zbus 5 → org.freedesktop.login1.Manager.Inhibit - KI-03 Windows sleep prevention: SetThreadExecutionState(ES_CONTINUOUS|...) - acquire/release_idle_inhibit Tauri commands, wired in DictationPage - Diagnostic-bundle frontend wire-up (Settings → Help button) - WCAG-AA contrast fix via .btn-filled-text utility (no token changes) - 8 destructive-action sites wrapped in plain-language confirm() guards - KNOWN-ISSUES.md + v0.1-known-limitations.md updated (KI-02/03 fixed) Scripts - pre-tag-verify.sh, tag-day.sh, smoke-linux + driver - parse-diagnostic-bundle.sh, parse-activation-log.py Per-item audit trail: docs/release/v0.1-completion-status.md Remaining: W-01…W-08 (signing certs, hardware probes, smoke matrix, tester recruitment) — see docs/release/v0.1-known-limitations.md.
268 lines
9.1 KiB
Svelte
268 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
// @ts-nocheck
|
|
import { onMount } from "svelte";
|
|
import { Trash2 } from "lucide-svelte";
|
|
import Toggle from "$lib/components/Toggle.svelte";
|
|
import { tasks } from "$lib/stores/page.svelte.js";
|
|
import { toasts } from "$lib/stores/toasts.svelte.js";
|
|
import {
|
|
createImplementationRule,
|
|
deleteImplementationRule,
|
|
implementationRules,
|
|
initialLastFiredKeyForTimeRule,
|
|
loadImplementationRules,
|
|
ruleSummary,
|
|
setImplementationRuleEnabled,
|
|
} from "$lib/stores/implementationIntentions.svelte.ts";
|
|
|
|
let loading = $state(false);
|
|
let saving = $state(false);
|
|
let error = $state("");
|
|
|
|
let triggerKind = $state("time_of_day");
|
|
let triggerTime = $state("09:00");
|
|
let surfaceEnabled = $state(true);
|
|
let surfaceTarget = $state("inbox");
|
|
let surfaceTaskId = $state("");
|
|
let startTimer = $state(false);
|
|
let speakLine = $state("");
|
|
|
|
let activeTasks = $derived(tasks.filter((task) => !task.done && !task.parentTaskId));
|
|
|
|
onMount(() => {
|
|
loading = true;
|
|
loadImplementationRules(true)
|
|
.catch((err) => { error = String(err); })
|
|
.finally(() => { loading = false; });
|
|
});
|
|
|
|
function triggerValue() {
|
|
return triggerKind === "time_of_day" ? triggerTime : "";
|
|
}
|
|
|
|
function builtActions() {
|
|
const actions = [];
|
|
if (surfaceEnabled) {
|
|
actions.push({
|
|
kind: "surface",
|
|
target: surfaceTarget,
|
|
taskId: surfaceTarget === "task" ? surfaceTaskId : null,
|
|
label: surfaceTarget === "task"
|
|
? activeTasks.find((task) => task.id === surfaceTaskId)?.text ?? "Task"
|
|
: null,
|
|
});
|
|
}
|
|
if (startTimer) {
|
|
const selectedTask = activeTasks.find((task) => task.id === surfaceTaskId);
|
|
actions.push({
|
|
kind: "start_timer",
|
|
seconds: 300,
|
|
taskId: surfaceTarget === "task" ? surfaceTaskId : null,
|
|
label: selectedTask?.text ?? "5-minute timer",
|
|
});
|
|
}
|
|
if (speakLine.trim()) {
|
|
actions.push({ kind: "speak_line", text: speakLine.trim() });
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
function canSave() {
|
|
if (saving) return false;
|
|
if (triggerKind === "time_of_day" && !/^\d{2}:\d{2}$/.test(triggerTime)) return false;
|
|
if (surfaceEnabled && surfaceTarget === "task" && !surfaceTaskId) return false;
|
|
return builtActions().length > 0;
|
|
}
|
|
|
|
async function saveRule() {
|
|
if (!canSave()) return;
|
|
saving = true;
|
|
error = "";
|
|
try {
|
|
await createImplementationRule({
|
|
enabled: true,
|
|
triggerKind,
|
|
triggerValue: triggerValue(),
|
|
actions: builtActions(),
|
|
lastFiredKey: triggerKind === "time_of_day"
|
|
? initialLastFiredKeyForTimeRule(triggerTime)
|
|
: null,
|
|
});
|
|
toasts.success("Rule saved");
|
|
triggerKind = "time_of_day";
|
|
triggerTime = "09:00";
|
|
surfaceEnabled = true;
|
|
surfaceTarget = "inbox";
|
|
surfaceTaskId = "";
|
|
startTimer = false;
|
|
speakLine = "";
|
|
} catch (err) {
|
|
error = String(err);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function toggleRule(rule, enabled) {
|
|
try {
|
|
await setImplementationRuleEnabled(rule.id, enabled);
|
|
} catch (err) {
|
|
toasts.warn("Could not update rule", String(err));
|
|
}
|
|
}
|
|
|
|
async function removeRule(rule) {
|
|
if (!confirm("Remove this if-then rule? Future captures won't be filtered by it. This cannot be undone.")) return;
|
|
try {
|
|
await deleteImplementationRule(rule.id);
|
|
} catch (err) {
|
|
toasts.warn("Could not delete rule", String(err));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-5">
|
|
<div>
|
|
<p class="text-[12px] text-text-secondary mb-4">
|
|
Build simple if-then rules. They respect Mute for now in Nudges, and everything stays local.
|
|
</p>
|
|
|
|
<div class="rounded-lg border border-border-subtle bg-bg-input p-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-[120px_1fr] gap-3 items-start">
|
|
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider pt-2">If</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<select
|
|
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
|
bind:value={triggerKind}
|
|
>
|
|
<option value="time_of_day">time of day</option>
|
|
<option value="task_completed">a task completes</option>
|
|
<option value="morning_triage_finished">morning triage finishes</option>
|
|
</select>
|
|
{#if triggerKind === "time_of_day"}
|
|
<input
|
|
type="time"
|
|
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
|
bind:value={triggerTime}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider pt-2">Then</p>
|
|
<div class="space-y-3">
|
|
<div class="flex flex-col gap-2">
|
|
<Toggle
|
|
bind:checked={surfaceEnabled}
|
|
label="Surface a list or task"
|
|
description="Open Tasks and put the chosen item back in view."
|
|
/>
|
|
{#if surfaceEnabled}
|
|
<div class="flex flex-wrap gap-2 pl-1">
|
|
<select
|
|
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
|
bind:value={surfaceTarget}
|
|
>
|
|
<option value="inbox">Inbox</option>
|
|
<option value="today">Today</option>
|
|
<option value="tasks">All tasks</option>
|
|
<option value="task">Specific task</option>
|
|
</select>
|
|
{#if surfaceTarget === "task"}
|
|
<select
|
|
class="min-w-[220px] bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
|
bind:value={surfaceTaskId}
|
|
>
|
|
<option value="">Choose task…</option>
|
|
{#each activeTasks as task (task.id)}
|
|
<option value={task.id}>{task.text}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<Toggle
|
|
bind:checked={startTimer}
|
|
label="Start a 5-minute timer"
|
|
description="Use the same focus timer as micro-steps."
|
|
/>
|
|
|
|
<div>
|
|
<label for="rule-speak-line" class="block text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">
|
|
Speak aloud
|
|
</label>
|
|
<input
|
|
id="rule-speak-line"
|
|
type="text"
|
|
maxlength="240"
|
|
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text placeholder:text-text-tertiary focus:border-accent"
|
|
placeholder="Optional line, e.g. time to plan the day"
|
|
bind:value={speakLine}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if error}
|
|
<p class="text-[12px] text-danger mt-3">{error}</p>
|
|
{/if}
|
|
|
|
<div class="flex justify-end mt-4">
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2 rounded-lg text-[12px] font-medium
|
|
{canSave()
|
|
? 'bg-accent text-bg hover:bg-accent-hover'
|
|
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'}"
|
|
disabled={!canSave()}
|
|
onclick={saveRule}
|
|
>
|
|
{saving ? "Saving…" : "Save rule"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="text-[12px] font-medium text-text-secondary uppercase tracking-wider mb-2">Saved rules</p>
|
|
{#if loading}
|
|
<p class="text-[12px] text-text-secondary">Loading…</p>
|
|
{:else if implementationRules.length === 0}
|
|
<p class="text-[12px] text-text-secondary italic">No rules yet.</p>
|
|
{:else}
|
|
<div class="space-y-2">
|
|
{#each implementationRules as rule (rule.id)}
|
|
<div class="flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-input px-3 py-2">
|
|
<button
|
|
class="relative w-[34px] min-w-[34px] h-[20px] rounded-full flex-shrink-0
|
|
{rule.enabled ? 'bg-accent' : 'bg-bg-elevated'}"
|
|
role="switch"
|
|
aria-checked={rule.enabled}
|
|
aria-label={rule.enabled ? 'Disable rule' : 'Enable rule'}
|
|
onclick={() => toggleRule(rule, !rule.enabled)}
|
|
>
|
|
<span
|
|
class="absolute top-[3px] left-[3px] w-3.5 h-3.5 rounded-full bg-white shadow-sm
|
|
{rule.enabled ? 'translate-x-[14px]' : 'translate-x-0'}"
|
|
style="transition: transform var(--duration-ui)"
|
|
></span>
|
|
</button>
|
|
<p class="flex-1 min-w-0 text-[12px] text-text-secondary leading-snug">
|
|
{ruleSummary(rule)}
|
|
</p>
|
|
<button
|
|
class="text-text-tertiary hover:text-danger"
|
|
aria-label="Delete rule"
|
|
title="Delete rule"
|
|
onclick={() => removeRule(rule)}
|
|
>
|
|
<Trash2 size={14} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|