Skip to content

fix(ui): fix table dark-mode theming; add UnthemedTextInBackgroundContainer lint rule#98

Merged
tstapler merged 6 commits into
mainfrom
stelekit-table-broken
May 27, 2026
Merged

fix(ui): fix table dark-mode theming; add UnthemedTextInBackgroundContainer lint rule#98
tstapler merged 6 commits into
mainfrom
stelekit-table-broken

Conversation

@tstapler
Copy link
Copy Markdown
Owner

@tstapler tstapler commented May 27, 2026

Summary

This PR bundles two bodies of work that are interdependent: three architectural refactoring commits that were prerequisites for the bug fix, and the table dark-mode theming fix itself.


Architectural refactoring (commits 1–3 — prerequisites for the fix)

These commits landed on the branch before the theming work because the new lint rule and several theming-layer components depend on the refactored interfaces.

fix(editor): serialize structural block ops through DatabaseWriteActor (24e889c964)

  • Routes splitBlock, mergeBlocks, and deleteBlockStructural through DatabaseWriteActor to prevent race conditions on structural edits
  • Adds chunked DeleteBlocksForPages logic to bound transaction size
  • Adds race-condition tests TC-N4a–TC-N4d covering concurrent structural operations

refactor(actor): replace non-atomic _activeOps counter with channel-based hasPendingWrites (7ad58d352d)

  • Removes the non-atomic integer counter that could under-count in-flight writes
  • Replaces it with a Channel-based hasPendingWrites signal so callers can await quiescence reliably

refactor(arch): apply code-review fixes — port interfaces, ISP split, scope ownership, watcher correctness (18ffc7d042)

  • ISP repository split: deletes monolithic GraphRepository.kt (~644 lines) and IBlockRepository.kt (~509 lines); replaces them with focused interfaces: BlockRepository, BlockReadRepository, BlockWriteRepository, BlockStructureRepository, BlockSearchRepository, PageRepository, PropertyRepository, ReferenceRepository, SearchRepository, RepositoryFactory
  • Port interfaces: introduces GraphLoaderPort, GraphWriterPort, GraphEvents.kt, GraphFileWatcher.kt to decouple the domain from infrastructure
  • Scope ownership: removes rememberCoroutineScope() from objects stored in remember { }; long-lived classes now own their own CoroutineScope(SupervisorJob() + Dispatchers.Default)
  • Watcher correctness: fixes GraphFileWatcher so external-change detection is not cancelled on recomposition
  • StelekitViewModelDependencies: parameter-object refactor of StelekitViewModel's constructor to keep the constructor stable across interface changes
  • Datascript*Datalog* rename: renames repository implementations throughout for naming consistency
  • clear() return type: changes clear() from Unit to Either<DomainError, Unit> across repositories for consistent error propagation
  • MarkdownPageParser extraction: moves inline parsing logic to a standalone class
  • FlashcardScheduler + FlashcardsScreen + ScreenRouter + GraphDialogLayer: adds flashcard scheduling UI and centralises screen/dialog routing
  • ClassLevelDirectRepositoryWriteOptInRule detekt rule (with tests): flags class-level @OptIn(DirectRepositoryWrite::class) to enforce per-call opt-ins in BlockStateManager

Bug-fix commits (the stated purpose of this PR — commits 4–6)

fix(ui): fix table dark-mode theming; add UnthemedTextInBackgroundContainer lint rule (ef2c31ee98)

  • Fixes table cells using hard-coded colours that were invisible in dark mode
  • Adds a custom detekt rule UnthemedTextInBackgroundContainer that flags Text() calls inside background-coloured containers when an explicit color argument is absent, preventing regressions of the same class of bug

fix(test): remove duplicate ParseTableContentTest from commonTest (3536cfbb89)

  • Removes a test class that was accidentally duplicated between commonTest and businessTest, causing a compile error after the ISP split

fix(lint): resolve pre-existing detekt violations (aae2753c28)

  • Cleans up detekt warnings introduced or surfaced by the refactoring above so CI passes cleanly

Why these are grouped

The theming lint rule (UnthemedTextInBackgroundContainer) and the duplicate-test cleanup both depend on the refactored repository interfaces being in place. Separating them would have required cherry-picking or a stacked-PR workflow; grouping them keeps the branch bisectable while keeping CI green at every commit.

Test plan

  • ./gradlew ciCheck passes (detekt + jvmTest + androidUnitTest + assembleDebug)
  • Table cells render correctly in both light and dark mode on desktop and Android
  • UnthemedTextInBackgroundContainer lint rule fires on a synthetic violation and is silent on compliant code
  • Race-condition tests TC-N4a–TC-N4d pass under jvmTest
  • No regressions in flashcard scheduling or screen routing

🤖 Generated with Claude Code

tstapler and others added 4 commits May 26, 2026 09:46
Fast typing followed by Enter caused new blocks to disappear or revert
because splitBlock/mergeBlock/handleBackspace called blockRepository
directly, bypassing the actor queue. A pending updateBlockContentOnly
enqueued in the actor would execute after the structural op, overwriting
the split/merge result with stale content.

Fix: add typed splitBlock/mergeBlocks/deleteBlockStructural methods to
DatabaseWriteActor and route all structural call sites in BlockStateManager
through the actor via private writeSplitBlock/writeMergeBlocks/writeDeleteBlockStructural
helpers (writeActor?.xxx() ?: blockRepository.xxx() fallback for tests).

Also adds hasPendingWrites (AtomicInt counter) to DatabaseWriteActor and
exposes hasActorPendingWrites on BlockStateManager so conflict detection in
StelekitViewModel catches external file changes that arrive while a
split/merge is in-flight.

Adds 4 race-condition tests that verify ordering under concurrent
content-write + structural-op scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ased hasPendingWrites

Two issues flagged in PR review:

1. `_activeOps` used `@Volatile Int` with `++`/`--` — not atomic; concurrent
   `execute()` callers could corrupt the count or make it go negative.

2. Counter was incremented before `send()` but decremented only after `await()`.
   If `send()` threw (closed channel, cancellation), the counter leaked indefinitely.

Fix: drop the explicit counter entirely. `hasPendingWrites` now derives state from
the channels themselves (`!highPriority.isEmpty || !lowPriority.isEmpty`) plus an
actor-owned `@Volatile Boolean` flag set inside the `processRequest` try/finally.
Channel.isEmpty is concurrency-safe; the flag is single-writer (actor coroutine),
so @volatile gives correct multi-reader visibility without atomics or new deps.

Also adds the ClassLevelDirectRepositoryWriteOptIn detekt rule (post-mortem
enforcement) and moves class-level @OptIn to function-level across BlockStateManager,
StelekitViewModel, AnnotationEditorViewModel, BacklinkRenamer, and
GlobalUnlinkedReferencesViewModel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… scope ownership, watcher correctness

Addresses all 31 findings from the post-architecture code review.

Key changes:
- GraphLoaderPort / GraphWriterPort: replace mutable var properties with explicit
  setter functions; add renamePage/savePage/deletePage to port so callers never
  need unsafe casts to the concrete type
- BlockRepository split into BlockReadRepository, BlockWriteRepository,
  BlockSearchRepository, BlockStructureRepository (ISP)
- ImportViewModel / ScreenRouter: class now owns its CoroutineScope; removed
  rememberCoroutineScope() leaking out of composition (C18 fix)
- GraphFileWatcher: fix SharedFlow suppression (Channel.RENDEZVOUS replaces broken
  yield() pattern), add close() to cancel owned scope, add pollIntervalMs parameter,
  log dropped events (C13/C14/C15/A3)
- MarkdownPageParser: extract to own file, replace Pair/Triple returns with typed
  PageBuildResult/PageMetadata, deduplicate mergedProperties, remove dead title alias
- FlashcardScheduler: extract SM-2 logic from @composable into pure stateless object
- GraphEvents.kt: move ExternalFileChange and WriteError out of GraphLoader
- AllPagesViewModel: depend on BlockSearchRepository, not full BlockRepository
- BlockWriteRepository.clear(): return Either<DomainError, Unit>
- Remove unused count field from DuplicateGroup
- StelekitViewModelDependencies: remove default scope value, require explicit supply
- GraphLoaderWatcherTest: replace vacuous assertTrue(count >= 0) with real assertion
- GraphFileWatcherTest: new suite covering suppression, close(), git-merge paths;
  fix virtual-time/real-time timeout mismatch and registry priming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tainer lint rule

`.background()` modifier changes the drawn colour but does not update `LocalContentColor`,
so `Text` without an explicit `color` inherits the wrong foreground in dark themes.

- TableBlock: add `.background(surface)` to outer Box and explicit `color` to TableCell Text
- Fix same pattern in LogDashboard, PDFViewer, Sidebar, Whiteboard, SettingsDialog, ImportScreen
- New detekt rule `UnthemedTextInBackgroundContainer` flags future regressions at lint time
- Rule registered in SteleKitRuleSetProvider and enabled in detekt.yml
- 10 rule tests + ParseTableContentTest + TableBlockScreenshotTest added
- buildSrc: add junit-platform-launcher so buildSrc tests run under Gradle 9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 27, 2026 00:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

The PR's stated purpose is to fix table dark-mode theming and introduce a new UnthemedTextInBackgroundContainer detekt rule. However, the diffs presented here are almost entirely test-side changes that support related refactors (a StelekitViewModelDependencies parameter-object wrapper, a DatascriptBlockRepositoryDatalogBlockRepository rename, and an Either-returning clear() signature on BlockRepository), plus two new test files (ParseTableContentTest, TableBlockScreenshotTest) and a new GraphFileWatcherTest.

Changes:

  • Migrate all StelekitViewModel(...) test call-sites to the new StelekitViewModelDependencies constructor wrapper.
  • Update FakeBlockRepository.clear() to the new Either<DomainError, Unit> signature and rename DatascriptBlockRepository to DatalogBlockRepository in GraphLoaderTest.
  • Add new tests: ParseTableContentTest, TableBlockScreenshotTest, GraphFileWatcherTest, plus tighter assertion in GraphLoaderWatcherTest.

Reviewed changes

Copilot reviewed 74 out of 74 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
StelekitViewModelLoadingTest.kt Migrate two construction sites to StelekitViewModelDependencies.
PageViewUITest.kt Same migration in two factory methods; adds import.
RecentPagesTest.kt Same migration for ViewModel construction.
DiskConflictResolutionTest.kt Same migration for ViewModel construction.
ComposeUITestBase.kt Same migration in shared test base.
FakeRepositories.kt Update clear() to return Either<DomainError, Unit>.
GraphLoaderTest.kt Rename DatascriptBlockRepositoryDatalogBlockRepository.
GraphLoaderWatcherTest.kt Replace permissive assertion with strict baseline-equality check on page count.
GraphFileWatcherTest.kt New unit-test file for GraphFileWatcher suppression/close/git-merge behavior.
ParseTableContentTest.kt New unit tests for the parseTableContent pure function.
TableBlockScreenshotTest.kt New semantic + Roborazzi screenshot tests for light/dark TableBlock.

Note on PR description discrepancy: The headline change (.background(MaterialTheme.colorScheme.surface) in TableBlock.kt, the new detekt rule, detekt.yml/SteleKitRuleSetProvider.kt, and buildSrc/build.gradle.kts) is not visible in the diffs presented here. If those files are part of this PR they should be reviewed; reviewing only the test-side artifacts is insufficient to validate the stated bug-fix and lint-rule behavior.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph.

Comparing 836625d (this PR) vs c61502a (baseline)
Device: API 30 x86_64 emulator — 530 pages loaded

Graph Load

Metric This PR Baseline Delta
Phase 1 TTI ↓ 93ms 87ms +6ms (+7%) ⚠️
Phase 3 index ↓ 4086ms 4115ms -29ms (-1%) ✅

Interactive Write Latency (during Phase 3)

Metric This PR Baseline Delta
Write p95 (baseline) ↓ 9ms 7ms +2ms (+29%) ⚠️
Write p95 (during phase 3) ↓ 13ms 17ms -4ms (-24%) ✅
Jank factor ↓ 1.44x 2.43x -0.99x (-41%) ✅
Concurrent writes ↑ 20 20 0 (0%)

SAF I/O Overhead (ContentProvider vs direct File read)

Measures Binder IPC cost added by ContentResolver per readFile() call.
Real SAF via ExternalStorageProvider will be higher on device; this is a lower bound.

Metric This PR Baseline Delta
Direct read / file ↓ 0.0ms 0.0ms 0 (0%)
Provider read / file ↓ 0.2ms 0.2ms +0ms (+18%) ⚠️
IPC overhead ratio ↓ 6x 5x +1x (+20%) ⚠️
↓ lower is better · ↑ higher is better

tstapler and others added 2 commits May 26, 2026 17:48
File was accidentally placed in both commonTest and jvmTest, causing a
Redeclaration compile error. jvmTest is the correct source set for this test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- FlashcardsScreen: mutableStateOf<Int/Float> → mutableIntStateOf/mutableFloatStateOf
- GraphDialogLayer: reorder composable params to put non-defaults first
- GraphWriter: prefix unused debounceMs param with _
- DatabaseWriteActor: extract inner block to reduce NestedBlockDepth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing 836625d (this PR) vs c61502a (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 10ms 9ms +1ms (+11%) ⚠️
Phase 2 background ↓ 2ms 3ms -1ms (-33%) ✅
Phase 3 index ↓ 15ms 14ms +1ms (+7%) ⚠️
Total ↓ 27ms 25ms +2ms (+8%) ⚠️
Write p95 (baseline) ↓ 45ms 36ms +9ms (+25%) ⚠️
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
Flamegraphs (this PR) **Allocation** — object allocation pressure (JDBC/SQLite churn)

Alloc flamegraph not available

CPU — method-level hotspots by on-CPU time

CPU flamegraph not available

Top SQL queries by total time (this PR) | table:operation | calls | p50 | p99 | max | total | |-----------------|-------|-----|-----|-----|-------| | `pages:select` | 2 | 1ms | 1ms | 1ms | 2ms |
Top allocation hotspots (this PR) `66.7%` byte[]_[k] `4%` java.lang.String_[k] `3%` java.util.concurrent.ConcurrentHashMap$Node_[k] `3%` java.lang.StringBuilder_[k] `1.5%` java.util.LinkedHashMap$Entry_[k]
Top CPU hotspots (this PR) `99.5%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0.1%` /tmp/sqlite-3.51.3.0-fa5638eb-449a-47ee-822a-52beb1117c2b-libsqlitejdbc.so `0.1%` pread `0%` app/cash/sqldelight/driver/jdbc/JdbcCursor.next_[0] `0%` SymbolTable::lookup_only

@tstapler tstapler merged commit be1652e into main May 27, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants