Add an e2e regression guard asserting the context-resolved
/active/daily-summary route opens the daily summary instead of falling
through to the wildcard and redirecting to the start page. The unit spec
only pins the navigation string; this verifies the string is a navigable
route.
The before-close confirm dialog's 'Finish Day' option navigated to a bare
'/daily-summary' route, which does not exist at the top level. It fell
through to the '**' wildcard and redirected to the start page, so the
daily summary never opened and the app appeared to do nothing.
Navigate to '/active/daily-summary' instead — resolved by
ActiveWorkContextGuard to the current context, matching the in-app
Finish Day button.
The Deck REST API returns a card's done field as a nullable completion
timestamp, not a boolean (proven by the write path that sends an ISO
string). The raw value was copied straight into task.isDone, so not-done
cards stored isDone: null, failing typia validation and triggering the
data-repair dialog on every startup/poll. In the batch poll path done
cards are filtered out, so every synced card had done: null.
Coerce to a real boolean at the API mapping boundary and correct the
DeckCardResponse.done type to string | null to reflect the API.
Fixes#8436
* fix(keyboard): resolve macOS global shortcut layout mismatch (#8378)
* fix(keyboard-layout): log layout-detection failure and resolve layoutReady with map copy
* fix(keyboard-shortcut): remove debug console logs and add macOS scope comment
* fix(keyboard-shortcut): preserve modifier separator when mapping plus key shortcut
* refactor(keyboard-shortcut): export mapping helpers and avoid as any cast in configuration mapping
* test(keyboard-shortcut): add unit tests for layout shortcut translation logic
* test(keyboard-shortcut): use correct KeyboardConfig type instead of as any in test fixture
* docs(keyboard-shortcut): hoist macOS physical shortcut layout comments to helper JSDoc
* refactor(config): extract global shortcut keys and mapping helpers
* test: add test helper capability for IS_ELECTRON and IS_MAC
* test: add comprehensive integration unit tests for global shortcut effects
* feat(electron): eagerly trigger layout detection on Electron startup for macOS timing fix
* refactor(config): InjectionToken migration, layout detection hardening, and startup optimization
* refactor: address non-blocking suggestions for layout detection and DI tokens
* build(electron): move keyboard-config.model to shared-with-frontend to fix ASAR require
* fix(client-id): increase entropy to 6 chars and fix related test regressions
* perf(sync): cache latest full-state op lookup
* fix(sync): harden full-state op metadata
* refactor(sync): dedupe full-state-ops meta helper + SQLite tests
Extract the FullStateOpRef/FullStateOpsMetaEntry shape and the
"latest = max UUIDv7" rule into full-state-ops-meta.ts, shared by the
upgrade-time seed (db-upgrade.ts) and the runtime maintenance
(operation-log-store.service.ts) so the two copies can't drift. The
shared builder always copies refs, removing the prior store-vs-upgrade
copy inconsistency (call sites already pass fresh arrays, so behavior is
unchanged).
Add store-service tests driving the full-state metadata pointer over a
real SQLite engine (sql.js), including the rebuild-on-read fallback that
keeps the pointer correct on SQLite, where the IndexedDB-only
populate-on-upgrade seed never runs.
* fix(sync): prune full-state meta atomically in clearFullStateOpsExcept
Read the meta pointer INSIDE the delete transaction instead of taking a
snapshot before it. The prior out-of-tx read could be clobbered by a
full-state append committed between the read and the write, dropping that
op's ref from the pointer while the op stayed in OPS — risking a stale
filtering baseline on the sync-receive path (packages/sync-core caller).
The OPERATION_LOG lock masks this today, but the method is now atomic and
self-consistent regardless of lock scope, matching deleteOpsWhere.
Also route the two hand-built meta writes (clearAllOperations,
runDestructiveStateReplacement) through buildFullStateOpsMeta so `latest`
is always derived from refs, never hand-asserted.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* feat(menu-tree): order project and tag dropdowns by tree
Add tree-order list helpers to MenuTreeService for projects and tags. The helpers flatten the stored menu tree depth-first, skip folder nodes, preserve tree sibling order, and append items missing from the tree so selectable projects and tags are never dropped.
Expose tree-ordered project and tag lists from ProjectService and TagService while keeping the existing sorted APIs for non-dropdown consumers such as short-syntax suggestions.
Switch project and tag selection dropdowns to the new tree-order APIs across task context menus, task rows, add-task bar controls, note project moves, formly project selection, start-page project selection, and tag edit/toggle menus.
Cover the ordering behavior with MenuTreeService, ProjectService, TagService, add-task-bar actions, and task context menu specs.
* fix(task-view-customizer): merge duplicate-titled tags when grouping
Distinct tag entities can share a title. _groupByTag keyed buckets by
tag.title but assigned with =, so a second same-titled tag overwrote the
first tag's tasks, dropping them from the grouped task view. Merge into
the title bucket instead, restoring the previous title-keyed grouping and
matching _getTagTitleOrderMap's single-slot contract.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* style(ui): fix persistent focus ring for slide toggles
* style(ui): enhance focus ring and hover for slide toggles
* style(ui): improve accessibility and focus-visible behavior for slide toggles
Durable follow-up to the #8295 revert. On targetSdk 36 (Android 16) edge-to-edge
is mandatory and adjustResize is a no-op for the IME, so the WebView only stays
above the soft keyboard if @capawesome/capacitor-android-edge-to-edge-support
insets it. The plugin instead zeroes the WebView bottom margin while the keyboard
is visible (assuming the system resized the window), leaving fixed content behind
the IME.
patch-package the plugin's EdgeToEdge.applyInsetsInternal so the WebView bottom
margin is always max(imeInsets.bottom, systemBarsInsets.bottom). The WebView then
shrinks above the keyboard, content resizes, and --keyboard-height stays 0 with
the add-task bar above the IME on the existing JS path. Wire patch-package via a
postinstall script; run 'npm install' to apply (also refreshes the lockfile).
DRAFT: verified the patch applies cleanly, but the gradle build and on-device
behavior are UNVERIFIED here. Needs the Android 10/14/15/16 matrix in
docs/android-edge-to-edge-keyboard.md before merge; upstream so the patch can be
dropped.
The store v18.10.0 build positions the global add-task bar correctly above
the soft keyboard and resizes normally. #8295 (merged after v18.10.0) is the
only keyboard-logic change since, and it regressed both: on this branch the
bar sits behind the IME and the view no longer appears to resize.
#8295 stacked two extra keyboard-height signals (a native physical-px height
via keyboardHeightPx$ and a baseInnerHeight-tracking VisualViewport path) on
top of the single VisualViewport `obscured` signal that shipped in v18.10.0,
combined as max(obscured, nativeKeyboardHeight - layoutShrink). The sources
fire on separate async events and race; scheduleCommit() resets
baseInnerHeight to the already-shrunk innerHeight mid-IME-animation, which
zeroes layoutShrink and defeats the double-count guard, so --keyboard-height
lands on a wrong value on devices that already handle the IME.
Revert to the known-good single-signal logic. The narrow Android 10 case
#8295 targeted will be re-addressed via a single WindowInsets IME listener
that is verified on a real device matrix.
Reverts #8295. Drops the orphaned calc-keyboard-height spec (its unrelated
#8421 backup-ring/state changes are kept).
* style(theme): give rainbow theme a neon glow makeover
Rework the bare rainbow theme into a full neon look inspired by a
glowing dashboard reference:
- deep indigo "space" palette with a fixed aurora nebula on
.app-container (mat-sidenav-content is not the work-area wrapper)
- frosted, glowing panels: side nav (violet bottom-glow), right panel,
cards, menus/dialogs, add-task bar — backdrop-filter on inner panes
only, never the side-nav host (keeps the mobile drawer) or task rows
- glowing active nav pill, primary buttons, chips, focus rings, links,
headings and a gradient neon scrollbar
- task rows get a translucent indigo fill (were transparent) so the
aurora tints through as glass; no per-row blur to protect the hot path
- task detail items now use the indigo --task-detail-* surfaces + glow
instead of falling back to neutral grey
- mark the theme requiredMode 'dark' so picking it applies a dark canvas
All glows are static (no animation) to stay GPU- and battery-friendly.
* test(recurring): pin clock in #6860 helper spec to fix date-dependent flake
The helper-based test hardcoded 15/06/2026 as the recurring start date but,
unlike its sibling specs, never pinned the clock. The datepicker disables
past days, so the scheduled run on 2026-06-16 could no longer click the
now-disabled 2026-06-15 cell and timed out. Pin today to 2026-05-01 (matching
the sibling recurring-date specs) so the hardcoded date stays a selectable
future day.
* fix(focus-mode): persist fullscreen notes when a session ends mid-edit
The fullscreen markdown editor is a detached CDK overlay. When a focus
session ends while it is open, focus-mode-overlay swaps screens
(Main -> SessionDone/Break) and destroys the inline-markdown that opened
the dialog. Its `changed` output then has no listener, so on Save the
note was emitted into the void and silently lost.
On afterClosed, when the component has already been destroyed, persist the
note directly to the store using the taskId captured when the dialog
opened, instead of emitting `changed`. Skip when the content is unchanged
(avoids writing the default-text placeholder back as a real note) and warn
when there is no taskId to persist to.
Covered by inline-markdown unit tests and a focus-mode e2e for both
Countdown and Pomodoro sessions completing mid-edit.
Clears the open Dependabot security advisories for the Angular runtime packages and tmp.
The whole @angular/* framework family is bumped together (21.2.14 -> 21.2.17): the packages use exact-version peer deps, so bumping @angular/core alone (as #8409 did) leaves the siblings at 21.2.14 and fails 'npm ci' with an ERESOLVE peer conflict. Supersedes #8409.
render-links.pipe (task titles, planner/schedule events, plain-text
notes) had its own scheme denylist predating the GHSA-hr87 allowlist, so
obsidian://, vscode://, mailto:, tel: etc. rendered as inert text there
even after the markdown renderer was fixed.
Route the pipe through the shared ALLOWED_EXTERNAL_URL_SCHEMES so all link
surfaces enforce one policy; preserve allow-listed schemes verbatim
instead of prefixing http://. Dangerous schemes (javascript:, data:,
vbscript:, ms-msdt:) still fall through to the scheme-shaped rejection.
The GHSA-hr87 scheme allowlist (#8210, v18.10.0) permitted only
http/https/mailto/file, which silently broke custom app deep-links such
as obsidian:// in task-attachment links and markdown notes.
Add low-risk app-launch + standard schemes (obsidian, vscode,
vscode-insiders, zotero, logseq, tel, sms) to the shared allowlist. This
restores the links across all sinks (OPEN_EXTERNAL, OPEN_PATH, the
navigation fallback, and the markdown link renderer) while still blocking
the OS-level handlers (ms-msdt:, search-ms:) the allowlist exists to stop.
The helper-based test hardcoded 15/06/2026 as the recurring start date but,
unlike its sibling specs, never pinned the clock. The datepicker disables
past days, so the scheduled run on 2026-06-16 could no longer click the
now-disabled 2026-06-15 cell and timed out. Pin today to 2026-05-01 (matching
the sibling recurring-date specs) so the hardcoded date stays a selectable
future day.
Rework the bare rainbow theme into a full neon look inspired by a
glowing dashboard reference:
- deep indigo "space" palette with a fixed aurora nebula on
.app-container (mat-sidenav-content is not the work-area wrapper)
- frosted, glowing panels: side nav (violet bottom-glow), right panel,
cards, menus/dialogs, add-task bar — backdrop-filter on inner panes
only, never the side-nav host (keeps the mobile drawer) or task rows
- glowing active nav pill, primary buttons, chips, focus rings, links,
headings and a gradient neon scrollbar
- task rows get a translucent indigo fill (were transparent) so the
aurora tints through as glass; no per-row blur to protect the hot path
- task detail items now use the indigo --task-detail-* surfaces + glow
instead of falling back to neutral grey
- mark the theme requiredMode 'dark' so picking it applies a dark canvas
All glows are static (no animation) to stay GPU- and battery-friendly.
* fix(backup): guard iOS backup against overwrite on transient read failure (#8414)
The iOS write path read the existing primary slot via _readIOSFileOrNull,
which swallows all errors and returns null. On a transient read failure that
made `existing` look like "no backup yet": the near-empty guard and prev-slot
promotion were skipped and the primary was overwritten with no prev copy
preserved — defeating the two-generation ring exactly when it is needed.
Add a write-path reader (_readIOSExistingSlotOrThrow) that distinguishes a
confirmed-absent slot (legitimate first backup → null, write proceeds) from a
transient read failure (re-probed via stat → rethrow), and make _backupIOS
bail without overwriting on uncertainty, mirroring _backupAndroid's guard.
The read/restore path keeps _readIOSFileOrNull's swallow-all behavior, which
is correct there.
* test(backup): cover first-ever iOS backup when Capacitor throws on absent file (#8414)
Adds an end-to-end _backup() regression test proving the overwrite guard does
not over-correct: when the raw read throws (Capacitor's signal for a missing
file) and stat confirms absence, the primary is still written. Complements the
transient-failure bail test.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Extract helpers that preserve public behavior and storage formats exactly:
archive-store.service.ts:
- _loadFromStore/_saveToStore/_hasEntry helpers for archive_young
and archive_old, eliminating 6 duplicated method bodies
operation-log-store.service.ts:
- _buildStoredEntry: shared entry construction for all append paths
- _handleAppendError: shared ConstraintError/QuotaExceeded handling
- _invalidateAppliedAndUnsyncedCaches: shared cache invalidation
state-snapshot.service.ts:
- SNAPSHOT_SELECTORS constant array, eliminates dual 17-selector
listing in _getNgRxDataSync and getStateSnapshotAsync
- _normalizeNgRxData for shared task field normalization
archive-operation-handler.service.ts:
- _guardArchiveOverwrite: consolidates empty-archive protection
for SyncImport/Repair/BackupImport across archiveYoung and
archiveOld
All existing tests pass. No behavioral changes, no public API changes.
Fixes#7920
* test(theme): cover calcKeyboardHeight defensive clamps
Add edge-case coverage for the two Math.max guards in calcKeyboardHeight:
a negative obscured area (visual viewport taller than the layout viewport,
e.g. pinch-zoom) clamps to 0, and a native keyboard height exceeding the
base inner height passes through without flipping negative.
* fix(tasks): persist add-task-bar draft note across close/reload
The inline note (noteTxt) lived only in memory, so closing the add-task bar
(e.g. an Escape in the title input) or reloading discarded a typed note with
no warning. Persist it to sessionStorage with the same init+effect pattern as
inputTxt so a draft note survives. Closes#8415 (note-loss parts).
* refactor(backup): key sync-enabled backup guard off SyncConfig
backupStrHasSyncEnabled read globalConfig.sync.{isEnabled,syncProvider} via
string-literal keys, so a future rename of those fields would silently weaken
the guard (a synced backup would read as non-synced and auto-restore). Type
the parsed shape against Pick<SyncConfig, ...> so a rename breaks the build
here instead. Closes#8414 (Gate B hardening).
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(tasks): add inline note field to add-task bar
Add a collapsible multi-line note/description field to the add-task bar
so a note can be entered together with the task (#6866).
- Note chip in the action-bar row toggles a textarea below the bar;
Ctrl/Cmd+Enter from the title also expands it, and submits from within
the note (plain Enter inserts a newline, Esc collapses).
- Collapsed by default; the chip is grayed until a note is entered.
- The trimmed note is written to task.notes on add (single create op).
- Remove a stray Log.x(taskData) debug line that would otherwise record
the user's note into the exportable log history.
* fix(tasks): surface add-task note chip on mobile and use the notes icon
- Move the note chip to the second position (after project) so it is
visible without horizontally scrolling the action row on narrow
screens. The previous trailing position was always off-screen.
- Use the chat / chat_bubble_outline icon that marks notes elsewhere
(task detail panel) instead of the generic "notes" icon.
Replaces the earlier sticky-pin attempt, which rendered an opaque fill
over the often semi-transparent add-task bar and read as a floating,
confusing control while scrolling.
* refactor(tasks): use two-way ngModel for the add-task note field
Multi-review follow-ups:
- Bind the note textarea with [(ngModel)]="stateService.noteTxt" instead
of the split [ngModel]/(ngModelChange), matching the sibling title
input. Add a DOM test confirming the signal two-way binding writes back
(the sibling input pairs it with an (input) handler, so the write path
was worth proving rather than assuming).
- Drop the now-dead isNoteExpanded mock from the actions spec; the chip
no longer reads it.
* style(tasks): drop stray blank line in add-task-bar actions template
* fix(backup): chunk Android backup reads to survive 2MB CursorWindow
KeyValStore.get() read the value via cursor.getString(), which throws SQLiteBlobTooBigException once a row exceeds Android's ~2MB CursorWindow. The on-device backup blob crosses 2MB after roughly a year of normal use, so the backup was written but never readable — surfacing as "Error invoking loadFromDb: Java exception" and silently breaking the #7901 eviction-recovery path. Combined with the data store living in evictable WebView IndexedDB (#7892), this caused total data loss on Android. Electron/iOS use file-based backups and are unaffected.
- KeyValStore.get(): read in 256K-char substr() chunks + try/catch->default; also recovers already-written oversized rows.
- KeyValStoreInstrumentedTest: emulator regression (Robolectric can't reproduce the CursorWindow limit).
- local-backup.service: _loadAndroidDbValueSafe() logs blob size (not content) to the exportable log and degrades a read failure to "no backup" instead of an opaque crash.
Closes#8401
* test(backup): cover Android KV read-degradation + write-path bail (#8402)
Adds CI-runnable Karma specs for the resilience logic from the fix commit: read degrades to null (never throws), and the write path bails without overwriting when the existing-slot read fails (preserving both ring slots). To make the window.SUPAndroid singleton (undefined off-device, no DI seam) testable, the native calls now route through thin _nativeDbLoad/_nativeDbSave methods the specs spy. The real ~2MB CursorWindow limit remains covered by KeyValStoreInstrumentedTest (emulator-only).
* fix(sync): redact WebSocket auth token from Caddy access logs (#8343)
The WS endpoint authenticates via ?token=<JWT> (browsers can't set
headers on a WebSocket), and that JWT is valid for 365 days with no
rotation. The shipped Caddyfile enables access logging, so every WS
upgrade wrote a year-valid, full-sync credential to stdout / docker
logs. Use a filter log encoder to redact the token query param from the
logged URI while keeping the request line for observability.
* fix(sync): retry failed remote ops as one seq-ordered batch (#8305)
retryFailedRemoteOps applied each failed op in its own single-op
applyOperations call. That turned every retry into a single-op batch, so
the same-batch archive pre-scan (collectArchivingOrDeletingEntityIdsFrom
Batch) no-op'd and silently weakened the #7330 orphan-resurrection
guard. Retry all failed ops as one batch instead, ordered by seq, and
mirror the primary remote-apply path's applied/slice-from-failure result
handling (keeping the MAX_CONFLICT_RETRY_ATTEMPTS reject cap).
* fixup! fix(sync): redact WebSocket auth token from Caddy access logs (#8343)
* refactor(sync): share slice-from-failure op-id logic across apply paths
Extract getFailedOpIdsFromBatch() — the 'failed op plus every op after it'
slice that retryFailedRemoteOps, ConflictResolutionService, and the
sync-core remote-apply path each derived inline. Removes the app-side
duplication flagged in review and fixes a latent bug: conflict-resolution
sliced from the findIndex result with no -1 guard, so an unfound failed op
would slice(-1) and mark only the last op failed. The shared helper guards
that case (marks just the failed op). Behavior is otherwise unchanged.
* test(sync): integration-test failed-op retry against real IndexedDB (#8305)
The retryFailedRemoteOps unit spec mocks the store, so it can't verify the
real status transitions the retry relies on. Add an integration spec that
drives retryFailedRemoteOps through the real OperationLogStoreService with
only the applier controlled, asserting: full-batch success clears ops to
applied, the batch is applied in one call, partial failure re-marks the
failed op plus every op after it, and a permanently-failing op is rejected
at MAX_CONFLICT_RETRY_ATTEMPTS across reboots and then no longer returned.
* style(ui): globalize task-detail field style to filled form fields
* style(theme): define CSS custom properties for input fields
* style(theme): refactor global form fields to use input tokens
* style(theme): override input tokens in zen theme to use hairline borders
* style(theme): override input tokens in velvet theme and remove redundant overrides
* style(theme): override Material 3 container shape radii to fit app-wide geometric rules
* Revert "style(theme): override Material 3 container shape radii to fit app-wide geometric rules"
This reverts commit 59f9620421.
* style(theme): refactor form fields error states, focus indicators, and theme refinements
* style(ui): round all corners of filled fields; drop redundant theme tokens
Material hardcodes the filled text-field's bottom corner radii to 0 and the
container-shape token only rounds the top two corners, so the added 1px border
rendered rounded-top/square-bottom instead of the intended card shape (and
regressed Velvet, which previously rounded all four corners). Force the full
border-radius via the shared --input-border-radius token.
Also drop two no-op token declarations surfaced in review:
- velvet: --input-border-color-focus duplicated the inherited base value
(both --c-primary and --focus-ring-color resolve to --palette-primary-500)
- zen: --input-bg: transparent in the dark block was already inherited from body
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
STEP 8 navigated to /project/:id without the /tasks suffix. PROJECT_CHILD_ROUTES
has no default child redirect, so the child router-outlet rendered empty and the
parent-task assertion only matched the lingering Today landing view — flaking on
master CI (retries:0) whenever that stale DOM was torn down under parallel load.
Navigate to /project/TEST_PROJECT/tasks and reload for a single clean render
(migration's Genesis op makes the reload skip re-migration). Verified 4/4 on
--repeat-each plus the full spec file.
The plugin iframe (srcdoc, v18.10.0) drove its visible UI through tokenless
PLUGIN_MESSAGE round-trips that the host accepted only when
`event.source === this.iframeRef.contentWindow`. Under zoneless change
detection the @ViewChild `iframeRef` is assigned on a later rAF/timeout CD
pass, which can land after the iframe has already posted its first messages.
Those messages were then dropped forever (postMessage is not redelivered) and
useTranslate has no timeout, so the panel stayed blank — intermittently,
depending on how the timing landed.
- Resolve the iframe window from the live DOM (host-scoped querySelector)
instead of the cached @ViewChild. The iframe must be in the DOM to have
posted a message, so this always finds it at message time, while staying
scoped to this component so sibling plugin iframes are not answered.
- Drop the redundant _cleanupIframeCommunication() on fresh mount: it set an
empty-document srcdoc that forced an extra iframe load + CD pass before the
real document, widening the race.
- Add PluginIndexComponent spec covering the dropped-message regression and
cross-iframe isolation.
Tag grouping and sorting in the task-view customizer ignored the
sidebar order: grouping rendered via the keyvalue pipe (alphabetical
key sort) and sorting compared the alphabetically-first tag title.
Both tag-toggle menus also disagreed - the inline one followed the
sidebar while the right-click context menu was alphabetical.
Introduce a shared MenuTreeService.flattenTagViewTree as the single
source of truth for sidebar (menu-tree) tag order, and route through it:
- sort-by-tag places a task by its highest-priority tag (lowest sidebar
index), falling back to task title for ties
- group headers are ordered by sidebar index (special buckets last);
non-tag groups keep their previous ascending order
- both tag-toggle menus now share the same flattened order
This removes the need to force order with numeric tag prefixes.
* refactor: remove dead SyncStateCorruptedError, compat exports, and 0-byte sync-providers barrel #8328
* docs(sync): drop stale SyncStateCorruptedError/fail-fast references (#8328)
The fail-fast dependency-resolution subsystem (DependencyResolverService +
SyncStateCorruptedError throw) was removed earlier; OperationApplierService now
bulk-dispatches ops in causal arrival order and returns a failedOp for the caller
to re-validate/retry. Update the sync architecture docs, archive-operations
diagram, and user-data wiki to match. Completes the dead-code cleanup for #8396.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
A warm paper theme ported from plainspace.org: cream surfaces, a
terracotta primary and a teal accent, in light and dark. Registered in
BUILT_IN_THEMES (requiredMode: system) and listed in the theming docs.
Surfaces/ink primitives drive the semantic tokens; the primary/accent
palette ramps recolor the brand. Dark-mode Category-B tokens (subtasks,
notes, selected rows, schedule events) are re-derived from the warm
surface ladder so nothing leaks the base cold-grey ramp.
Signature paper touches:
- squared graph-paper backdrop (reuses the body::before layer)
- hand-drawn terracotta underlines on headings and collapsible headers
- a hand-drawn rule under each board column header in place of a panel box
- warm soft paper shadows (--card-shadow split per mode) and tighter
3/6/8px radii
The ambient primary-tinted gradient wash is removed (repurposed to the
grid); remaining gradients are contextual and left untouched.
* fix(backup): auto-restore on-device backup on blank mobile launch and harden durability (#7901)
Durability follow-ups to #7901 / #7892 (Android total data loss when the
WebView IndexedDB is evicted while the durable on-device backup survives).
- Mobile auto-restore: on a blank/evicted launch (no state cache, but a
usable on-device backup exists) restore the newest usable backup
automatically instead of behind a confirm dialog users routinely dismiss.
Only triggers for a genuinely empty live store (a deliberate in-app delete
leaves a valid empty state cache and is not auto-restored); falls back to
the informed prompt for corrupt/data-less blobs. Reports counts via snack.
- KeyValStore.onUpgrade no longer DROPs the table. It holds the durable
on-device backup (keys backup / backup_prev); a destructive upgrade would
wipe it the moment DATABASE_VERSION was bumped. Upgrades are now additive
(CREATE TABLE IF NOT EXISTS), preserving rows.
- Record the last successful local-backup time (after the meaningful-data
guard) and surface a "Last backup: <date>" line in the mobile Automatic
Backups settings, so no-sync users can see they're protected; the
timestamp also rides along in exported logs for eviction diagnosis.
Docs: correct 3.06-User-Data (Android backup is native app-private SQLite,
not WebView IndexedDB) and document the auto-restore + timestamp behavior.
Tests: auto-restore (usable / import-failure / corrupt-fallback / no-backup)
and getLastBackupTime in local-backup.service.spec; config-page spy updated.
https://claude.ai/code/session_01E5YzMGtfMr33qQLjMM2SGA
* fix(backup): gate mobile auto-restore to empty op-log + non-synced backups (#7901)
Hardening from the multi-agent review of the previous commit's mobile
auto-restore. Two gates so silent auto-restore fires only in the
unambiguously safe case (eviction of a local-only user's store):
- Gate A (startup.service): only consider a backup restore when the op-log is
empty (getLastSeq()===0), not merely when the state-cache snapshot is absent.
A null cache alone can still have real ops the hydrator is concurrently
replaying; auto-restoring then was unnecessary and raced the hydrator's
replay against importCompleteBackup's destructive op-log replacement.
- Gate B (local-backup.service): only silently auto-restore a backup that had
NO sync configured. Restoring a synced backup resets lastServerSeq and writes
a clean-slate BACKUP_IMPORT, which can silently drop other devices' concurrent
work, so a synced backup now goes through the informed confirm prompt instead.
Also corrects the misleading comment (cited a non-existent in-app "delete all
data" flow) and adds tests: backupStrHasSyncEnabled units; auto-restore
data-less / synced / disabled-sync cases; config-page last-backup line
shown/omitted cases.
https://claude.ai/code/session_01E5YzMGtfMr33qQLjMM2SGA
* fix(backup): only advance last-backup time on a real write (#7901)
Review follow-ups to the #7901 durability work:
- _backup() recorded LAST_LOCAL_BACKUP unconditionally after the platform
writers, so the per-platform A3 near-empty guard (#7925) skipping a write
still advanced the "Last backup" time — falsely claiming "just backed up"
on exactly the post-eviction boot the guard protects. Platform writers now
return whether they actually wrote; the time is recorded only then. Adds a
spec for the skip case.
- Correct the LAST_LOCAL_BACKUP comment: the value is never passed to Log.*,
so it does not "ride along in exported logs" — it is only surfaced in
Settings.
- Document askForFileStoreBackupIfAvailable's blank-store precondition (empty
op-log) on the method itself, not just inline.
---------
Co-authored-by: Claude <noreply@anthropic.com>
The #8331 transient-download regression test failed on every scheduled
run since it landed (abortedResolutionGets stayed 0). The CI trace shows
why: B's pre-upload download is correctly stripped and B's upload IS
rejected CONFLICT_CONCURRENT, but the ops-upload conflict comes back as
HTTP 200 whose body piggybacks the conflicting remote op in `newOps`.
The client LWW-resolves the conflict from that piggyback in-place, so
RejectedOpsHandlerService never issues the separate resolution-download
GET the #8331 fix guards — the path the test arms and aborts.
Fix: the route handler now also forwards B's upload POST to the real
server (keeping the rejection server-computed) and strips
`newOps`/`hasMorePiggyback` from the response. With no piggybacked ops,
the client falls back to the resolution-download GET, which the test
aborts — exercising the guarded catch. A new `strippedConflictPiggybacks`
guard asserts this fired. This stripped-piggyback condition is real in
production when the conflict falls beyond PIGGYBACK_LIMIT (500 ops).
No product code changed; behavior under test is unchanged.
The comment claimed getComputedStyle resolves env() to a pixel value, so the
_patchCdkViewportForSafeArea readers pick up the real top inset. That is false:
reading an unregistered custom property whose value is env(...) returns the
unresolved token string (env()/var() are substituted only when a real property
uses the value, not when the custom property's own value is read), so
parseInt() yields 0. The CSS padding (var(--safe-area-top)) still self-corrects
at use-time; only the JS readers see 0 - the same as before this change, so no
regression. Comment-only.
The two raw-SQL latest-writer lookups unnested a mutually-exclusive
`CASE WHEN cardinality(entity_ids) > 0 THEN entity_ids ELSE ARRAY[entity_id]`,
so a stored op whose scalar entity_id is NOT a member of its own entity_ids
(producible via getStoredEntityIds dedup) was invisible to the batch lookup -
a later concurrent op touching that entity was wrongly accepted (silent data
loss). This only bites once batch upload is enabled in prod, but is a real
latent divergence between the single-entity (Prisma OR) and batch (raw SQL)
paths.
Fold the scalar into the set with a union:
entity_ids || CASE WHEN entity_id IS NULL THEN '{}'::text[] ELSE ARRAY[entity_id] END
(entity_id is nullable; the guard prevents array||NULL nulling the whole
array). DISTINCT ON dedupes the common entity_id = entityIds[0] overlap. The
WHERE prefilter is unchanged, so GIN(entity_ids) + entity_id btree usage is
preserved. Update the conflict-detection.spec mock to union too, and add a
DATABASE_URL-gated PG integration test exercising the real detectConflict /
prefetchLatestEntityOpsForBatch (divergent-scalar case fails under the old
CASE); excluded from the default vitest run like the sibling SQL integration test.
Follow-ups to the #8309 in-tab sync-cycle guard:
- forceUpload() now claims SyncCycleGuard (released in finally), mirroring
_forceDownload(), so its setLastServerSeq bookkeeping and session-validation
latch can't race the immediate-upload / WS-download side channels. All
forceUpload call sites are deferred snackbar/dialog callbacks that run after
the originating cycle released the guard, so it cannot self-skip.
- WsTriggeredDownloadService now skips downloads while an encryption operation
is in progress (isEncryptionOperationInProgress), mirroring
ImmediateUploadService, so a WS notification can't trigger a download/decrypt
while keys or a full-state import are mid-flight (e.g. password change, which
does not hold the cycle guard). Read via lazyInject() to break the DI cycle
(SyncWrapperService injects WsTriggeredDownloadService to start/stop the WS).
A LockAcquisitionTimeoutError during op capture errored the whole
persistOperation$ stream: concatMap tore down and silently dropped every
buffered action, the positional capture FIFO leaked an entry so
flushPendingWrites() could never reach 0 (every sync then failed after
30s), and after NgRx's 10-resubscribe cap the effect died until reload.
Fix, bundled with the #8318 cleanup:
- Replace the positional FIFO queue with a pending counter. The
meta-reducer increments it; the effect decrements it in a `finally`
(writeOperationFromEffect), so a thrown write can never leak the flush
signal. The decrement runs after the write commits + lock releases,
preserving the flush commit-ordering invariant.
- The effect catches per write so one failure never tears down the shared
stream (the resubscribe-death and silent-drop fixes).
- entityChanges is now computed in the write path via the pure
extractEntityChanges(); the `[]` field is still emitted (Android reads
it; isMultiEntityPayload requires it).
- writeOperation keeps its throw for the #7700 deferred retry loop
(that path bypasses the wrapper and is not counted). This also
structurally removes the #8307 double-dequeue.
Adds operation-log-effect-stream-survival.regression.spec.ts (stream
survives lock timeout, counter drains on always-fail, survives >10
failures) and updates the capture/flush/integration specs + sync docs.
* test(sync): cover multi-entity conflict SQL against real Postgres (#8334)
Add an in-process Postgres (PGlite) spec that runs the actual unnest(CASE...)
conflict-detection SQL from conflict.ts, covering the non-first-entity case,
latest-per-entity selection, scalar fallback for pre-migration rows, and the
NULL-entity_id full-state op. Unlike the DATABASE_URL-gated integration specs
(excluded from the default config), this runs in the normal npm test job.
* fix(sync): delete all entityIds on multi-entity DEL replay (#8340)
Server op-replay deleted only the scalar entityId, so a batch delete
(deleteTasks stores its full set in entityIds, scalar = entityIds[0]) left
entities 2..n alive in restore snapshots, which feed back to clients. Thread
entityIds (now persisted, #8334) through replay and delete the full set, with
the same prototype-pollution guard and scalar fallback for single-entity and
pre-migration ops. Add entityIds to REPLAY_OPERATION_SELECT.
The held-POST + re-entrant A-sync ordering was still racy: B's upload was
accepted (200) instead of rejected, so the resolution-download catch under
test never ran and abortedResolutionGets stayed 0 (runs 27465357634 and
27467427713).
Switch to a deterministic hybrid that keeps the real server conflict:
- pre-sync A so its concurrent op is unconditionally on the server;
- strip ops from B's pre-upload download (return the REAL response with an
emptied ops list) so B never merges A's edit and uploads its original
concurrent op -> the real server computes a genuine vector-clock
CONFLICT_CONCURRENT;
- abort every post-upload resolution GET to drive the guarded catch.
Pin latestSeq back to the request's sinceSeq on the stripped download: the
client persists lastServerSeq even on an empty download, so keeping the
advanced seq would make B skip A's edit forever and break convergence.
Add a strippedPreUploadDownloads setup guard alongside abortedResolutionGets
so a setup that fails to provoke the conflict fails loudly, not vacuously.
The countRejectedOps discriminator and end-to-end convergence are unchanged.
The focus-mode overhaul (#7586) dropped the {{cycle}} variable from the
English LONG_BREAK_TITLE/SHORT_BREAK_TITLE strings, since the cycle is now
rendered separately as digits below the label. The break label is shown via
| translate with no interpolation params, so the orphaned {{cycle}} that
remained in all 27 locale files rendered as literal text.
Strip the '- <cycle> {{cycle}}' suffix from both keys in every locale to
match the English source; the cycle number is still shown by the cycle
counter.