diff --git a/docs/superpowers/specs/2026-07-12-alpha-product-ux-design.md b/docs/superpowers/specs/2026-07-12-alpha-product-ux-design.md index ef23579..4ea6408 100644 --- a/docs/superpowers/specs/2026-07-12-alpha-product-ux-design.md +++ b/docs/superpowers/specs/2026-07-12-alpha-product-ux-design.md @@ -31,6 +31,8 @@ designs: - Passive browser activity records only a normalized hostname and measured duration. It never records a page URL, title, selection, page content, keystrokes, or browsing history. +- Passive browser tracking is disabled on a new installation. It starts only + after explicit, informed consent in extension settings. - Passive browser activity counts only the active tab in a focused browser window. Background tabs and unfocused browser windows contribute no time. - Activity can suggest a Journal record, but the user reviews and saves it. @@ -52,37 +54,117 @@ retry queue remain separate from passive activity data. ### Domain activity tracker -The extension adds a small persisted tracker: +Passive tracking is an opt-in extension setting, `passiveActivityEnabled`, with +a default of `false`. The first extension settings view includes a concise +consent card and an unchecked switch. It states that the feature sends only a +normalized hostname and duration, and explicitly states that it does **not** +collect or send URLs, titles, page content, selections, keystrokes, or browser +history. The same explanation remains next to the switch after onboarding. + +Until the user enables that switch, the extension does not subscribe to +tracking events, create activity state, or send activity records. Disabling it +stops tracking immediately, clears only the mutable unflushed accumulator, and +leaves already acknowledged Desktop activity unchanged. The user can either +retry or discard already-created pending batches through an explicit settings +action; disabling tracking never silently loses them. + +When enabled, the extension uses a persisted tracker: 1. When an HTTP(S) page becomes the active tab of a focused browser window, start timing its normalized lowercase hostname. -2. On tab activation, hostname change, window focus loss, or a five-minute - alarm, calculate elapsed time and add it to that hostname's local total. +2. On tab activation, hostname change, window focus loss, browser idle/lock, + or a five-minute alarm, write a checkpoint and calculate elapsed time. The + idle detection threshold is explicitly ten minutes; a locked state pauses + immediately. 3. Ignore browser-internal pages, invalid URLs, and excluded hostnames. An exclusion `youtube.com` matches that hostname and every subdomain; the same rule applies to `x.com`. -4. On a flush, send each accumulated hostname as a `browser.activity.domain` - record. The payload contains a schema version, idempotency ID, observed +4. On a flush, freeze one record per hostname as a `browser.activity.domain` + batch. The payload contains a schema version, idempotency ID, observed period bounds, hostname, and `durationSeconds`; it contains no URL-like field. -5. Remove a hostname's sent total only after Desktop confirms that particular - idempotency ID. On failure, shutdown, or service-worker suspension, preserve - the timestamp and totals in extension local storage and retry later. -The extension uses timestamp arithmetic rather than an in-memory interval, so -the behaviour survives Chromium Manifest V3 service-worker restarts. Its -manifest gains only the `windows` and `alarms` permissions needed for this -flow; existing tab access is retained. Firefox uses the equivalent WebExtension -events. +The persisted state has three distinct parts: + +```text +activeAccumulator: mutable, unsent duration and bounds by hostname +pendingBatches: immutable payloads, keyed by idempotency ID +acknowledgedIds: bounded recent acknowledgement IDs +``` + +Flushing copies a hostname's current accumulator into a new immutable pending +batch with a newly generated ID, then clears only that copied accumulator. New +time for the same hostname accumulates in `activeAccumulator` and can become a +later batch. It never mutates an already sent batch. Retries send the stored +payload byte-for-byte. A successful acknowledgement removes only the matching +pending batch and records its ID; it cannot remove newer time. Pending batches +are sent oldest first. `acknowledgedIds` is a 30-day bounded LRU set used to +handle replayed acknowledgement messages safely; Desktop maintains its own +idempotency store as the authority. + +The tracker uses timestamp arithmetic but applies a conservative ambiguity +limit. A checkpoint contributes elapsed time only if wall-clock time is +monotonic and the gap from the previous trustworthy checkpoint is no more than +ten minutes. A negative clock delta, a gap over ten minutes, an idle/locked +state, or a browser startup after a crash discards the ambiguous interval and +establishes a fresh baseline. WebExtensions have no portable system +suspend/resume event, so the first post-suspend observation is deliberately +handled by this gap rule; platform idle/lock events add an earlier pause where +available. This can undercount ambiguous work but cannot turn an overnight +sleep or a clock change into working time. A browser startup never carries an +active interval across process death; pending batches and accumulated completed +intervals are retained. + +The extension persists timestamps and the three state parts in local extension +storage, so a Manifest V3 service-worker restart can continue safely. It sets +the `idle` API detection interval to ten minutes. Its manifest gains the +`windows`, `alarms`, and `idle` permissions needed for this flow; existing tab +access is retained. Firefox uses the equivalent WebExtension events when +available and otherwise applies the same ten-minute checkpoint limit. The settings page gets an **Excluded domains** list. It accepts one hostname per -item, normalizes case and leading dots, rejects a scheme/path/query, and -explains that a hostname excludes its subdomains. The default list is empty. +item through the canonical normalization below, and explains that a hostname +excludes its subdomains. The default list is empty. Adding an exclusion stops +the matching active measurement immediately and discards only its mutable +unflushed time; immutable pending batches remain available for the user's +explicit retry or discard decision. + +### Canonical hostname normalization + +Every extension event, exclusion, and Desktop domain binding uses +`hostname-normalization-v1`. It is a normative shared contract, not an +implementation detail of one component: + +1. An activity source must be an HTTP(S) URL. Its port, user info, path, query, + and fragment are discarded before hostname normalization. A binding or + exclusion must be a bare hostname; schemes, paths, queries, fragments, and + ports are rejected. +2. Trim surrounding whitespace, lowercase, and remove exactly one DNS root + trailing dot. `example.com.` therefore becomes `example.com`. +3. Convert DNS names using non-transitional UTS #46 / IDNA lookup processing to + an ASCII A-label. The canonical stored and compared form of `пример.рф` is + its punycode A-label; the settings UI may render the Unicode display form. +4. IPv4 addresses are accepted in canonical dotted-decimal form. IPv6 literals + are accepted (bracketed only at URL/settings input), stored without brackets + in canonical lower-case RFC 5952 form, and never include a port. `localhost` + and syntactically valid single-label internal names are also accepted. +5. Reject empty values, malformed IP literals, invalid labels, empty labels, + labels over 63 ASCII bytes, DNS names over 253 ASCII bytes, and any input + that fails URL/IDNA parsing. Invalid activity is not counted; invalid + settings input gets an inline validation error and is not saved. + +The SDK owns a versioned `hostname-normalization-v1` test-vector corpus. Desktop +and the browser extension vendor the byte-identical corpus in their tests; the +coordinated `build-all` verification checks its hash. Go and JavaScript have +separate implementations, but both must pass every vector, including trailing +dots, Unicode/punycode equivalence, IPv4, IPv6, localhost, internal names, +ports, malformed input, and excessive lengths. ### Desktop receiver Desktop exposes an authenticated activity receiver separate from the capture -receiver. It validates a bounded hostname, positive bounded duration, ISO time +receiver. It normalizes and validates a hostname with +`hostname-normalization-v1`, validates a positive bounded duration, ISO time fields, schema version, and idempotency ID before it publishes `browser.activity.domain`. Invalid and duplicate records do not enter Activity. The existing local pairing token gates the endpoint; the token is never placed @@ -102,11 +184,16 @@ loaded when the plugin host starts, not only while the plugin view is mounted. The Activity service subscribes once to public events, normalizes and persists them, rebuilds candidates, and releases subscriptions on host teardown. -The platform provides unsubscribe-capable event subscriptions. Activity writes -use the existing atomic plugin-settings update primitive, preventing concurrent -browser/file events from overwriting each other. Command registration is also -owned by the background service so commands remain available without opening -the Activity view. +The platform provides unsubscribe-capable event subscriptions. Command +registration is owned by the background service so commands remain available +without opening the Activity view. + +Raw Activity data is not held in `settings.json`. The Desktop storage layer +provides a lifecycle-safe, plugin-scoped append-only event log for +`verstak.activity` under plugin data. Appending one event does not rewrite the +whole log. Plugin settings retain only compact preferences; plugin data retains +candidate watermarks and indexes. Appends and compaction are serialized by the +storage layer, preventing lost updates. ### Candidate rules @@ -114,17 +201,43 @@ Activity presents chronological sessions rather than a raw log by default. A browser activity session displays, for example, `admin.client-site.ru · 1 ч 32 мин`; it exposes no hidden URL/title data. -A candidate is scoped to one existing Дело and local calendar day. It is ready -when either: +A logical session is scoped to one existing Дело and is split only by a +different Дело, a 20-minute idle gap, or 120 minutes of total session span. It +is not split merely because midnight passes. A session is ready when either: - meaningful events in its session cover at least ten minutes and include at least two events; or - it contains one or more browser-domain records totaling at least ten minutes. `workspace.selected`, `file.opened`, and `note.opened` are diagnostic context, -not meaningful work by themselves. Candidate identity is stable by case, local -day, and source event IDs. Accepted and dismissed state is persisted, so an -already handled candidate does not reappear after a restart. +not meaningful work by themselves. + +Each logical session receives a stable `sessionId` derived from its case and +first ordered event; the service stores that ID on each appended event and +preserves the session-start metadata through log compaction. It persists an +ordered handled watermark +`{ occurredAt, activityId }` and optional state for the latest reviewed slice. +A candidate contains only source events after that watermark: + +- **Accepting** a candidate stores its `sessionId` and handled watermark on the + Journal entry. Later activity can create a candidate only for the additional + interval after that watermark and only when that new interval reaches the + normal threshold. +- **Dismissing** consumes the current candidate slice through its watermark. + `A+B` therefore cannot reappear as `A+B+C`; only qualifying new work after + `B` may later be suggested. +- A dismissal is not a permanent ban on future work in the same logical + session. At least ten new meaningful minutes are required before it can be + suggested again. + +An event arriving late at or before a handled watermark remains in the raw +diagnostic log but never reopens an accepted or dismissed candidate slice. + +An across-midnight logical session remains one candidate. The review displays +its time apportioned by local date, preselects the date containing the largest +share of the duration (ties use the start date), and lets the user choose the +Journal date. This preserves a 23:50–00:30 session instead of losing its first +ten minutes to an artificial threshold. The Journal review action opens the existing journal editor with candidate duration, date, and a concise domain/event summary. The user may edit all @@ -137,6 +250,20 @@ Activity has two clear views: - **Сессии** — default, with candidate cards and activity summaries; - **События** — a secondary diagnostic stream for technical event inspection. +### Retention and deletion + +The alpha retains raw Activity events for at most 60 days, 10,000 events, or +8 MiB of log data, whichever limit is reached first. On append and at service +startup, bounded compaction removes the oldest entries until all limits hold; +it does not block the UI. Candidate state is pruned once its session and every +related Journal source watermark are older than 60 days. + +Saving a Journal entry does not delete its source Activity events. The Journal +entry retains the compact source session/watermark reference after raw-event +retention removes those events. **Clear activity for this Дело** removes only +that case's raw events, sessions, and candidate state after confirmation. +**Clear all activity** is a separate global confirmed operation. + ## Browser Inbox lifecycle ### Record state @@ -145,7 +272,9 @@ A capture remains one canonical record with independent fields: ```text globalState: active | archived -workspaceRootPath: optional existing Дело +workspaceRef: active | trashed | unavailable | orphaned +workspaceRootPath: optional active or historical Дело path +workspaceTrashId: optional stable trash identity ``` Existing records migrate to `globalState: active`; their current assignment is @@ -159,16 +288,25 @@ non-destructive for an alpha user vault. - **Убрать из общих входящих** changes `globalState` to `archived`. It never removes the assignment, so an assigned capture remains available from its Дело. -- **Открепить от дела** removes only `workspaceRootPath`. If the capture is +- **Открепить от дела** removes only the `workspaceRef` and + `workspaceRootPath`. If the capture is still globally active it returns to the global Inbox; otherwise it remains in archive. - **Удалить везде** removes the canonical capture and its assignment. It is a separate destructive action and requires confirmation that names the affected Дело when assigned. -- **Сохранить ссылку в деле** creates an independent `.url` file under the - case's `Links/` directory, creating that directory first. Success does not - depend on retaining the capture record; later Inbox deletion cannot remove - the written file. +- **Сохранить ссылку в деле** is available only for a valid HTTP(S) capture URL. + It opens a small save dialog with an editable proposed filename derived from + the capture title, then its hostname, then `Link`. The stem is sanitized for + cross-platform forbidden characters and control characters, limited to 96 + characters, and receives `.url`. The file uses InternetShortcut format and + is created through the plugin `files.write` capability under the case's + `Links/` directory, creating that directory first. A name collision never + overwrites silently: the dialog offers an explicit `name (2).url` alternative + or cancellation. Read-only/error cases leave the capture unchanged and show + the failure. Success publishes the normal safe file/link Activity event; the + written file opens through the existing user-initiated OS-default-browser + file action. Bulk actions operate only on the visibly filtered capture set, show the count, and use archive rather than permanent deletion. Every permanent deletion and @@ -178,14 +316,54 @@ When a Дело is renamed, the Inbox service migrates its capture assignments a exact domain bindings from the old root to the new root. It does not migrate a binding that has been manually changed to another Дело during that operation. +When a Дело goes to Trash, assignments and bindings become `trashed` with the +Desktop trash ID. They remain visible as unavailable historical context but are +not used for automatic routing. A restore event carrying that trash ID restores +the assignment/binding, including a restored path changed by a collision. A +permanent trash purge changes captures to unassigned and changes bindings to a +visible `orphaned` state that the user can reassign or remove. Activity remains +historical and labels the case as deleted. If a case disappears by external +filesystem change, active references become `unavailable`, automatic routing is +disabled, and the user must explicitly reassign or remove them; a newly created +folder with the same name never steals those references. + +### Archive and filters + +Global Browser Inbox has **Active**, **Archive**, and **All** status filters; +Active is the default. Search applies within the chosen filter, so Archive is +searchable deliberately through Archive or All rather than unexpectedly +appearing in the normal queue. **Restore to Inbox** changes `globalState` back +to `active`. Archive supports a visible filtered bulk restore with a count and +confirmation. A capture assigned to a Дело remains visible in that Дело's Inbox +regardless of its global archive state, with an Archive badge. + ## Alpha interface - Use Russian product labels consistently: **Дела**, **Входящие**, **Активности**, and **Журнал**. User-facing dates use the local time zone. -- The overview contains only useful, properly scoped items: continuation for - the selected Дело, new Inbox records, Journal candidates, and real recent - changes. It excludes repeated `file.changed`, `workspace.selected`, and - unrelated-case events. +- With a selected Дело, the overview reads only records explicitly scoped to + that Дело; an unscoped global event never leaks into every case. With no + selected Дело, it shows a short prompt to select/create one plus up to five + active unassigned Inbox records; it does not fabricate a blended continuation + feed. +- **Needs attention** shows at most five entries: ready Journal candidates, + active unprocessed Inbox records, and existing urgent Todos, in that order + within their respective priority. An Inbox record is new/needs attention + while it is active and unprocessed, without an arbitrary age cutoff. +- **Continue work** shows at most four distinct case-scoped entities from the + last 14 days: unfinished Todo, unprocessed capture, and the most recent + note/file/Journal entity. Every item carries `lastMeaningfulAt`; items sort + descending by that value, with an unprocessed capture, Todo, Journal, note, + then file as deterministic ties. Multiple `file.changed` events for the same + entity collapse to one item. Opening an item does not mark it complete. Its + overflow action **Hide recommendation** stores a non-destructive + entity/event dismissal; a later meaningful change to that entity makes it + eligible again. +- **Recent changes** shows at most eight distinct case-scoped records from the + last seven days. Included events are note save/create, file create/rename or + last change per file, saved browser link, and Journal create. Technical + selection/open events are excluded. The empty state says that there were no + changes in that period. - Empty states give a next action, not an empty pane. - Clear, delete, and archive actions name their scope and consequences. @@ -202,8 +380,8 @@ plugin permissions, or release behaviour. ## Error handling and privacy - A failed extension delivery keeps the accumulated hostname time locally and - reports a non-blocking retry state in extension settings; it never falls back - to Browser Inbox capture. + reports the pending-batch count and a non-blocking retry state in extension + settings; it never falls back to Browser Inbox capture. - Receiver authentication/validation errors provide a safe status to the extension without echoing the pairing token or untrusted payload. - Activity never manufactures a case from missing assignment data. @@ -213,18 +391,21 @@ plugin permissions, or release behaviour. Automated checks must cover: -- browser tracker start/stop, focused-tab-only accounting, exclusions, - coalescing, acknowledgement-only reset, retry persistence, and payload - privacy; -- Desktop activity receiver authentication, validation, idempotency, binding, - and event publication; -- atomic Activity persistence, background subscription lifecycle, candidate - threshold/state, Journal handoff, local dates, and missing-Journal feedback; -- assigning, archiving, unlinking, permanent deletion, `.url` creation, - filtered bulk operations, and renaming an assigned Дело; +- browser tracker explicit consent/disable behaviour, focused-tab-only + accounting, exclusions, canonical hostname vectors, immutable pending + batches, acknowledgement-only reset, retry persistence, payload privacy, + negative clock changes, long gaps, restart, lock, and suspend/resume; +- Desktop activity receiver authentication, canonical normalization, + validation, idempotency, binding, and event publication; +- append-only Activity log retention/compaction, background subscription + lifecycle, handled watermarks, across-midnight review, Journal handoff, + local dates, case-scoped clear, and missing-Journal feedback; +- assigning, archiving, restoring, unlinking, permanent deletion, `.url` + naming/collision/readonly behaviour, filtered bulk operations, rename, trash + restore/purge, and external-workspace unavailability; - normal and debug-mode plugin tab labels; -- the corrected frontend Wails mock, plus the end-to-end flows - activity-to-Journal and Inbox-to-Дело. +- the corrected frontend Wails mock, deterministic Overview limits/scoping, + plus the end-to-end flows activity-to-Journal and Inbox-to-Дело. Manual GUI smoke testing verifies Russian normal-mode labels, hidden plugin IDs, candidate review, Inbox preservation, and extension domain exclusions in