# Design Patterns A living catalogue of patterns we use in Hammer. When you're about to write something new, check here first — there's a good chance the problem has a canonical solution already. If a pattern in this doc looks wrong for your case, that's fine — talk through why and add the new variant. The point is shared vocabulary, not rules. --- ## Deep Linking > **TL;DR** — Navigation flows through typed callbacks threaded from > `ProjectRootComponent` down through `ProjectRootRouter` into each leaf > component. Destinations are typed data (`SceneItem`, `EntryDef`, …), not > raw IDs. The screen-stack owner (`ProjectRootComponent`) is the only thing > that knows how to *find* the destination component on the stack. ### When to use this Any time a click somewhere should open another screen and land on a specific piece of content — opening a scene from a stat block, opening an encyclopedia entry from a character chip, opening a note from a search result, jumping to the timeline from a reference. ### Canonical example: Global Search Global Search is the broadest deep-linker in the app — its results can land on a scene, a note, an encyclopedia entry, or a timeline event. Read that flow first; everything else is a subset of it. **1. The result type encodes its destination.** ```kotlin // common/.../components/globalsearch/GlobalSearch.kt sealed class SearchResult { data class Scene(val sceneItem: SceneItem, …) : SearchResult() data class Note(val noteId: Int, …) : SearchResult() data class EncyclopediaEntry(val entryDef: EntryDef, …) : SearchResult() data class TimelineEvent(val eventId: Int, …) : SearchResult() } ``` Each variant carries exactly the typed data the destination component needs. Not a row id and a string — the actual `SceneItem` / `EntryDef`. **2. The leaf component takes a navigation callback.** ```kotlin // common/.../components/globalsearch/GlobalSearchComponent.kt class GlobalSearchComponent( …, private val navigateToResult: (SearchResult) -> Unit, ) : GlobalSearch { override fun onResultClicked(result: SearchResult) = navigateToResult(result) } ``` The leaf component knows nothing about routers, decompose stacks, or other screens. It just hands the typed result back to whoever wired it. **3. `ProjectRootComponent` is the only thing that resolves "where to go".** ```kotlin // common/.../components/projectroot/ProjectRootComponent.kt private fun navigateGlobalSearchResult(result: SearchResult) { when (result) { is SearchResult.Scene -> { showEditor() (routerState.value.active.instance as? Destination.EditorDestination) ?.component?.showScene(result.sceneItem) } is SearchResult.EncyclopediaEntry -> showEncyclopediaEntry(result.entryDef) … } dismissGlobalSearch() } ``` This is the dispatch point. Two steps for every destination: 1. Activate the right screen in the stack (`showEditor()`, `showNotes()`, …). 2. Pull the freshly-activated destination's component off `routerState` and call its `showX(...)` method to land on the specific item. Helpers like `showEditorScene(sceneItem)` and `showEncyclopediaEntry(entryDef)` exist on `ProjectRootComponent` precisely so other navigation entry points (focus mode reopen, search result, dashboard click) all go through the same two-step flow. ### Adding a new deep link Concrete recipe — this is the pattern we used for the dashboard's "open the longest scene" / "open this character's entry" affordances. **1. Pick the destination's typed data.** If the destination already accepts `SceneItem` / `EntryDef` / etc., reuse it. Do **not** invent a new "id + name" tuple — see *Resolver gotcha* below. **2. Add an action to the source component's interface.** ```kotlin // ProjectHome.kt interface ProjectHome : … { fun showLongestScene() fun showEntry(entry: EntryAppearance) } ``` The action's arguments are whatever the source UI has on hand — a click context, an appearance summary. The component's job is to translate that into the destination's required type. **3. Add a constructor callback to the source component.** ```kotlin // ProjectHomeComponent.kt class ProjectHomeComponent( …, private val onShowScene: (SceneItem) -> Unit, private val onShowEntry: (EntryDef) -> Unit, ) : ProjectHome { override fun showLongestScene() { val id = _state.value.longestSceneId ?: return val sceneItem = sceneEditorRepository.getSceneItemFromId(id) ?: return onShowScene(sceneItem) } override fun showEntry(entry: EntryAppearance) { val def = encyclopediaRepository.findEntryDef(entry.entryId) ?: return onShowEntry(def) } } ``` Two things to notice: - The component does the **resolve step** (id → `SceneItem`, id → `EntryDef`). This is the right place for it: the component already has the repositories injected, and resolving at *click time* (not cache time) means the data is fresh even if names/types have drifted. - Both methods bail with `?: return` if the destination can't be resolved. Don't navigate to a stale id. **4. Thread the callback through `ProjectRootRouter`.** ```kotlin // ProjectRootRouter.kt private fun home(…): ProjectHome = ProjectHomeComponent( …, onShowScene = showScene, onShowEntry = showEntry, ) ``` `ProjectRootRouter` already has the `showScene` / `showEntry` lambdas wired by `ProjectRootComponent` — you're just connecting an existing pipe to a new consumer. **5. The UI calls the interface method.** ```kotlin // ProjectStatsUi.kt HdAttributionItem( label = entry.name, …, onClick = { onShowEntry(entry) }, ) ``` The UI never reaches across to a router or another component. It calls a method on the component it already has. ### Resolver gotcha — don't reconstruct typed destinations from cached fields When the source data is a denormalized cache (a stats snapshot, a search index entry), it's tempting to hand-build the destination type from the fields you already have: ```kotlin // ⚠️ Don't do this val def = EntryDef( projectDef = projectDef, id = entry.entryId, type = entry.type, name = entry.name, ) ``` This works *today* but silently drifts if the user renames the entry or changes its type between when the cache was built and when they click. Always go through the canonical resolver: ```kotlin // ✅ Authoritative lookup val def = encyclopediaRepository.findEntryDef(entry.entryId) ?: return val sceneItem = sceneEditorRepository.getSceneItemFromId(id) ?: return ``` If the canonical resolver doesn't exist for your destination type, add one to the repository — don't replicate it inline. ### Why this shape - **No global state.** Navigation is just function references passed down. Each component is testable with a fake callback. - **The router is centralized but the *intent* is decentralized.** Anywhere in the app can express "open this scene" without knowing how the stack is arranged. Only `ProjectRootComponent` knows the stack. - **Type-safe destinations.** A `SceneItem` parameter can't be confused with a `NoteId`. The compiler enforces the deep link contract. - **One resolve point per destination.** `showEditorScene(SceneItem)` is the one path into the editor with a target scene — focus mode reopen, search result, dashboard click all funnel through it. ### File map | File | Role | |-----------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------| | [ProjectRoot.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt) | Top-level navigation interface | | [ProjectRootComponent.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt) | Owns the screen stack, defines `showEditorScene` / `showEncyclopediaEntry` | | [ProjectRootRouter.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootRouter.kt) | Constructs leaf components, injects the navigation callbacks | | [GlobalSearchComponent.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/globalsearch/GlobalSearchComponent.kt) | Canonical deep-link source — sealed `SearchResult` carries the typed destination | --- ## Scene-Editing Domain API > **TL;DR** — Components and UI talk to the scene-editing domain **only through > `SceneEditorService`** (plus a couple of purpose-built UseCases like > export/import). The three data repositories behind it — > `SceneRepository` (tree/structure/paths/sync-hash), `SceneContentRepository` > (buffers/autosave), `SceneMetadataRepository` (scene + project metadata) — are > lower-level building blocks. Components never inject them directly. ### When to use this Any Component (or component-scoped UseCase) that needs to create/rename/move/ delete/archive a scene, read or edit a scene's buffer, read/write scene metadata, or observe the scene list. There is exactly one door: inject `SceneEditorService`. A caller never has to decide "repo or service?" ### Why this shape The scene-editing domain was historically one ~1300-line `SceneEditorRepository` that bundled ~12 responsibilities and depended sideways on sibling repos and *up* on a Service-level tracker. It was decomposed into: - **`SceneEditorService`** — the single Component-facing API. Owns write *orchestration* (it applies the cross-cutting side-effects — statistics, reference-index deltas, writing-activity, sync-marking — around each command), composes the derived scene-list, and delegates reads one-to-one to the owning repository. Wide surface, but thin: all real logic/state lives in the repos. - **`SceneRepository`** — the scene tree, structure, path computation, and sync-hash. Pure data; depends only on datasources + the foundational `IdRepository`/`SyncDataRepository`. Does **not** know about statistics, references, or writing-activity. - **`SceneContentRepository`** — the buffer/editing engine: in-memory buffers, the content debounce pipeline, temp-buffer autosave, dirty tracking, and the `bufferUpdateFlow`. Emits a "buffer persisted" signal the service hangs save side-effects off, rather than reaching up into those collaborators itself. - **`SceneMetadataRepository`** — scene + project metadata (pure persist + the `metadataUpdateFlow`); the reference-index delta on a confirmed-references change is orchestrated by the service, not here. The repositories are still consumed *directly* by other Services, UseCases, and the sync layer (Service→Repository / UseCase→Repository edges are legal). Only the **Component tier** is restricted to the service. > **Note (Stage 1).** A few reverse edges remain by design — sibling repos like > `SceneDraftRepository`/`GlobalSearchRepository` still depend on the scene > repos, and `SceneRepository` still leans on its sibling sub-repos for > project-scope init and scene-list dirty-buffer composition. Resolving those > (e.g. reclassifying `GlobalSearchRepository` as a Service, pushing `Id`/`Sync` > into a true foundational layer) is deferred to Stage 2. ### File map | File | Role | |------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| | [SceneEditorService.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneEditorService.kt) | The single Component-facing API: orchestration + derived state + reads | | [SceneRepository.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneRepository.kt) | Tree / structure / paths / sync-hash (pure data) | | [SceneContentRepository.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneContentRepository.kt) | Buffers, content debounce, temp-buffer autosave, lifecycle | | [SceneMetadataRepository.kt](../common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/data/sceneeditorrepository/SceneMetadataRepository.kt) | Scene + project metadata |