-- allium: 1 -- bDS UI/UX Flows and Save Side-Effects -- Scope: cross-cutting (all waves) -- Distilled from: appStore.ts, PostEditor.tsx, MediaEditor.tsx, -- SettingsView.tsx, PostEngine.ts, MediaEngine.ts, TemplateEngine.ts, -- ScriptEngine.ts, MetaEngine.ts, TagEngine.ts, NotificationWatcher.ts -- Describes the reactive data flows that connect sidebar, editor, tab bar, -- and backend engine operations. Covers: sidebar->editor, editor->sidebar, -- cross-tab coordination, and automatic side-effects on save/publish/delete. use "./layout.allium" as layout use "./tabs.allium" as tabs use "./sidebar_views.allium" as sidebar use "./editors.allium" as editors use "./post.allium" as post use "./media.allium" as media use "./search.allium" as search -- ─── External surfaces ────────────────────────────────────── -- User-initiated navigation and entity management actions. -- These triggers originate from sidebar clicks, tab operations, -- dashboard interactions, and save/delete actions in editors. surface UserNavigation { provides: SidebarItemClicked(entity_type, entity_id, click_type) provides: SidebarCreateRequested(entity_type) provides: SidebarDeleteRequested(entity_type, entity_id) provides: DashboardPostClicked(post_id, click_type) provides: PostSaved(post_id, updated_post) provides: PostStatusTransitioned(post_id, old_status, new_status) provides: PostDeletedFromEditor(post_id) provides: MediaSavedFromEditor(media_id, updated_media) provides: MediaDeletedFromEditor(media_id) provides: SettingsRebuildCompleted(entity_type, new_data) provides: TransientTabBeingReplaced(old_tab, new_tab) provides: TabClosed(tab) provides: AISuggestionRequested(entity_type, entity_id) provides: PostAutoTranslateCompleted(post_id, language) } -- Engine-level events emitted after backend operations complete. -- These are NOT direct user actions — they fire as side-effects -- of user operations processed by the engine layer. surface Engine { provides: PostCreated(post) provides: PostUpdated(post, changes) provides: PostPublished(post) provides: PostDeleted(post) provides: PostChangesDiscarded(post) provides: MediaImported(media) provides: MediaUpdated(media, changes) provides: MediaFileReplaced(media, new_file) provides: MediaDeleted(media) provides: TemplateCreated(template) provides: TemplateUpdated(template, changes) provides: TemplatePublished(template) provides: TemplateDeleted(template, force) provides: TagDeleted(tag) provides: TagRenamed(old_name, new_name) provides: TagsMerged(source_tags, target_tag) provides: ProjectMetadataUpdated(metadata) provides: CategoryAdded(name) provides: CategoryRemoved(name) provides: PublishingPreferencesUpdated(prefs) provides: PostTranslationUpserted(translation, source_post) provides: MediaTranslationUpserted(translation, media) provides: MediaTranslationDeleted(media, language) } -- ═══════════════════════════════════════════════════════════════ -- PART 1: UI DATA FLOW MODEL -- ═══════════════════════════════════════════════════════════════ -- All UI coordination flows through a shared reactive store (Zustand in TS, -- Iced subscriptions in Rust). There are no direct calls between sidebar -- and editor. Both read from and write to the store; changes propagate -- reactively. -- ─── 1.1 Sidebar -> Editor flows ────────────────────────────── rule SidebarEntityClick { when: SidebarItemClicked(entity_type, entity_id, click_type) -- Single click: open as transient/preview tab -- Double click: open as pinned tab -- See sidebar_views.allium for per-view click rules -- Effect: Editor mounts the matching view keyed by entity_id -- Effect: Sidebar item highlights based on activeTabId match -- If a prior transient tab of same type exists, it is replaced, -- and the old editor unmounts (triggering auto-save if dirty) } rule SidebarCreateEntity { when: SidebarCreateRequested(entity_type) -- Posts/Pages: backend creates post, emits post:created, -- store adds to posts list, sidebar re-renders with new item. -- Does NOT open a tab automatically. User must click to open. -- Sets selectedPostId for highlighting, optionally ensures sidebar visible. -- Scripts/Templates: backend creates, emits event, opens tab immediately. -- Chat: creates conversation, opens as pinned tab. -- Import: creates definition, opens as pinned tab. } rule SidebarDeleteEntity { when: SidebarDeleteRequested(entity_type, entity_id) -- Scripts/Templates/Chat/Import: backend deletes entity, -- then closeTab(entity_id) removes its tab, -- activates adjacent tab or shows dashboard. -- Posts/Media: deletion is triggered from the EDITOR (not sidebar). -- See PostEditorDelete / MediaEditorDelete below. } invariant SidebarFilterIsolation { -- Sidebar search/filter state is local to the sidebar component. -- Filtering never affects: active tab, editor content, selectedPostId. -- Only the visible list of items changes. } -- ─── 1.2 Editor -> Sidebar flows ────────────────────────────── rule PostTitleChanged { when: PostSaved(post_id, updated_post) -- Auto-save fires after 3s idle, or on Ctrl+S, or on unmount/tab switch -- Store's posts array is updated with new title/metadata -- Sidebar PostsList re-renders reactively showing new title -- TabBar re-renders showing new title ensures: dirtyPosts.remove(post_id) } rule PostStatusChanged { when: PostStatusTransitioned(post_id, old_status, new_status) -- Store's posts array is updated with new status -- Sidebar detects status change (compares prev/current status maps) -- and re-runs search/filter if active -- Post moves between draft/published/archived sections in sidebar } rule PostEditorDelete { when: PostDeletedFromEditor(post_id) ensures: store.removePost(post_id) -- Removes from posts array, clears selectedPostId if matching, -- removes from dirtyPosts ensures: closeTab(post_id) -- Sidebar re-renders without the post } rule MediaEditorSave { when: MediaSavedFromEditor(media_id, updated_media) ensures: store.updateMedia(media_id, updated_media) -- Sidebar MediaList re-renders with updated metadata } rule MediaEditorDelete { when: MediaDeletedFromEditor(media_id) ensures: store.removeMedia(media_id) -- Editor.tsx safety net: detects active media tab references -- non-existent item, calls closeTab(activeTab.id) -- Sidebar re-renders without the media item } rule SettingsRebuild { when: SettingsRebuildCompleted(entity_type, new_data) -- Wholesale replacement of posts or media array in store ensures: store.setPosts(new_data) or store.setMedia(new_data) -- Sidebar re-renders entirely with fresh data } -- ─── 1.3 Cross-tab coordination ────────────────────────────── invariant TabSwitchDoesNotChangeSidebarView { -- Switching tabs does NOT change activeView in the sidebar. -- The sidebar view is controlled exclusively by the Activity Bar. -- Exception: Dashboard post click explicitly sets activeView = "posts". } invariant SidebarHighlightFollowsActiveTab { -- Sidebar item highlight is based on activeTabId === entity.id, -- NOT on selectedPostId/selectedMediaId (which are separate concepts -- used only for post creation flow). } rule TransientTabReplacement { when: TransientTabBeingReplaced(old_tab, new_tab) -- Old editor unmounts -> cleanup auto-save fires if dirty -- New editor mounts with new entity -- Sidebar highlight shifts to newly active entity } rule TabCloseCleanup { when: TabClosed(tab) -- If post tab: PostEditor unmounts, auto-save fires if dirty -- Store activates adjacent tab (prefer right, then left, then null) -- Sidebar highlight updates to new active tab -- If no tabs remain: dashboard shown } rule DashboardPostClick { when: DashboardPostClicked(post_id, click_type) -- This is the ONLY place where opening a tab also switches sidebar view ensures: setActiveView("posts") ensures: setSelectedPost(post_id) if click_type = single: ensures: OpenTabRequested(type: post, id: post_id, intent: preview) if click_type = double: ensures: OpenTabRequested(type: post, id: post_id, intent: pin) } -- ─── 1.4 Backend -> UI event propagation ───────────────────── -- Backend engines emit events via IPC. The renderer listens and updates -- the shared store. Both sidebar and editor re-render reactively. -- Events: post:created, post:updated, post:deleted, -- media:imported, media:updated, media:deleted, -- template:created/updated/deleted, script:created/updated/deleted, -- entity:changed (from CLI/MCP mutations via NotificationWatcher) -- TabBar also listens directly for: -- post-updated (title refresh) -- bds:scripts-changed (script title refresh) -- BDS_EVENT_TEMPLATES_CHANGED (template title refresh) -- chat.onTitleUpdated (chat conversation title refresh) -- importDefinitions.onNameUpdated (import name refresh) -- Editor.tsx has safety-net useEffect guards that: -- Close media tabs when referenced media no longer exists in store -- Clear selectedPostId/selectedMediaId when entity is gone -- ═══════════════════════════════════════════════════════════════ -- PART 2: ENGINE-LEVEL SAVE SIDE-EFFECTS -- ═══════════════════════════════════════════════════════════════ -- When an entity is saved/published/deleted in the engine layer, a chain -- of automatic side-effects fires. These are NOT UI-level concerns -- -- they happen in the backend regardless of which UI triggered them. -- ─── 2.1 Post operations ───────────────────────────────────── rule CreatePostSideEffects { when: PostCreated(post) ensures: FTSIndexUpdated(post) ensures: EmbeddingUpdated(post) -- No file written (draft lives in DB) } rule UpdatePostSideEffects { when: PostUpdated(post, changes) -- If post is published and content/metadata changes: -- auto-transition status back to draft -- If slug changed and file exists: rename .md file -- If templateSlug changed on published post: rewrite .md frontmatter ensures: FTSIndexUpdated(post) if changes.content: ensures: PostLinksUpdated(post) -- Parses markdown/HTML links, resolves slugs to post IDs, -- replaces outgoing link rows ensures: EmbeddingUpdated(post) } rule PublishPostSideEffects { when: PostPublished(post) ensures: PostFileWritten(post) -- posts/YYYY/MM/{slug}.md with YAML frontmatter if old_file_path != new_file_path: ensures: OldPostFileDeleted(old_file_path) ensures: post.content = null -- Content cleared from DB; lives in filesystem only ensures: FTSIndexUpdated(post) ensures: PostLinksUpdated(post) -- Also publishes all translations: for t in post.translations: ensures: TranslationFileWritten(t) ensures: t.content = null ensures: EmbeddingUpdated(post) } rule DeletePostSideEffects { when: PostDeleted(post) if post.file_path != "": ensures: PostFileDeleted(post.file_path) ensures: PostLinksDeleted(post) -- Deletes both source and target link rows for media_link in post.linked_media: ensures: MediaSidecarUpdated(media_link.media_id) -- Removes post from media sidecar's linkedPostIds ensures: FTSIndexDeleted(post) ensures: EmbeddingRemoved(post) } rule DiscardPostChangesSideEffects { when: PostChangesDiscarded(post) -- Reads published version from file, restores DB metadata, -- sets content=null, status=published ensures: FTSIndexUpdated(post) } -- ─── 2.2 Media operations ──────────────────────────────────── rule ImportMediaSideEffects { when: MediaImported(media) ensures: MediaFileWritten(media) -- media/YYYY/MM/{uuid}.{ext} ensures: SidecarFileWritten(media) -- {path}.meta with YAML-like metadata if media.is_image: ensures: ThumbnailsGenerated(media) -- small=150px, medium=400px, large=800px, ai=448x448 -- Asynchronous, emits thumbnailsGenerated on completion ensures: FTSIndexUpdated(media) } rule UpdateMediaSideEffects { when: MediaUpdated(media, changes) ensures: SidecarFileRewritten(media) -- Preserves fields caller didn't set (linkedPostIds, author) ensures: FTSIndexUpdated(media) } rule ReplaceMediaFileSideEffects { when: MediaFileReplaced(media, new_file) -- Copies new file over existing path if media.is_image: ensures: ThumbnailsRegenerated(media) -- Synchronous (awaited), not fire-and-forget } rule DeleteMediaSideEffects { when: MediaDeleted(media) ensures: MediaFileDeleted(media) ensures: SidecarFileDeleted(media) ensures: ThumbnailsDeleted(media) ensures: PostMediaLinksDeleted(media) ensures: MediaTranslationsDeleted(media) -- Also deletes all translated sidecar files: {path}.{lang}.meta ensures: FTSIndexDeleted(media) } -- ─── 2.3 Template operations ───────────────────────────────── rule CreateTemplateSideEffects { when: TemplateCreated(template) ensures: TemplateFileWritten(template) -- templates/{slug}.liquid with YAML frontmatter } rule UpdateTemplateSideEffects { when: TemplateUpdated(template, changes) ensures: template.version = template.version + 1 -- DB-first update, then filesystem; rollback DB on filesystem failure if changes.slug: ensures: TemplateFileRenamed(template) ensures: CascadeSlugUpdate(template) -- Updates posts.templateSlug and tags.postTemplateSlug ensures: TemplateFileRewritten(template) } rule PublishTemplateSideEffects { when: TemplatePublished(template) ensures: TemplateFileWritten(template) ensures: template.content = null -- Content cleared from DB } rule DeleteTemplateSideEffects { when: TemplateDeleted(template, force) if has_references and not force: -- Return without deleting, report reference counts ensures: nothing if force: ensures: ReferencingPostsCleared(template) ensures: ReferencingTagsCleared(template) -- Nulls out templateSlug on posts, postTemplateSlug on tags ensures: TemplateFileDeleted(template) } -- ─── 2.4 Script operations ─────────────────────────────────── -- Same pattern as templates: -- Create: write .lua file, insert DB -- Update: bump version, rewrite file, update DB -- Publish: write file, clear DB content -- Delete: delete file, delete DB row -- ─── 2.5 Tag operations ────────────────────────────────────── rule DeleteTagSideEffects { when: TagDeleted(tag) -- Background task: -- For each post containing this tag: -- Remove tag from post's tags array in DB -- If published: rewrite .md file (syncPublishedPostFile) ensures: TagsJsonWritten() -- meta/tags.json updated } rule RenameTagSideEffects { when: TagRenamed(old_name, new_name) -- Background task: -- For each post containing old_name: -- Replace old_name with new_name in tags array -- If published: rewrite .md file ensures: TagsJsonWritten() } rule MergeTagsSideEffects { when: TagsMerged(source_tags, target_tag) -- Background task: -- For each source tag, for each post containing it: -- Replace source with target (dedup), update DB -- If published: rewrite .md file -- Delete all source tag rows ensures: TagsJsonWritten() } -- ─── 2.6 Settings/Metadata operations ──────────────────────── rule UpdateProjectMetadataSideEffects { when: ProjectMetadataUpdated(metadata) ensures: ProjectJsonWritten() -- meta/project.json (atomic write) ensures: CategoryMetaJsonWritten() -- meta/category-meta.json (atomic write) } rule AddCategorySideEffects { when: CategoryAdded(name) ensures: ProjectJsonWritten() ensures: CategoryMetaJsonWritten() ensures: CategoriesJsonWritten() -- meta/categories.json } rule RemoveCategorySideEffects { when: CategoryRemoved(name) ensures: ProjectJsonWritten() ensures: CategoryMetaJsonWritten() ensures: CategoriesJsonWritten() } rule UpdatePublishingPreferencesSideEffects { when: PublishingPreferencesUpdated(prefs) ensures: PublishingJsonWritten() -- meta/publishing.json (atomic write) } -- ─── 2.7 Translation operations ────────────────────────────── rule UpsertPostTranslationSideEffects { when: PostTranslationUpserted(translation, source_post) -- If source is published and this is a manual edit (not auto-publish): -- transition source post to draft (copies content from file to DB) if source_post.status = published and translation.is_manual_edit: ensures: source_post.status = draft ensures: source_post.content = read_file(source_post.file_path) -- If both translation and source are published: -- write translation file, clear translation content from DB if translation.status = published and source_post.status = published: ensures: TranslationFileWritten(translation) ensures: translation.content = null ensures: FTSIndexUpdated(source_post) -- FTS includes all translation content for the source post } rule UpsertMediaTranslationSideEffects { when: MediaTranslationUpserted(translation, media) ensures: TranslatedSidecarWritten(media, translation.language) -- {path}.{lang}.meta } rule DeleteMediaTranslationSideEffects { when: MediaTranslationDeleted(media, language) ensures: TranslatedSidecarDeleted(media, language) } -- ─── 2.8 CLI/MCP notification sync ─────────────────────────── -- When MCP CLI makes mutations, it writes to db_notifications table. -- NotificationWatcher polls DB file (chokidar, 100ms debounce): -- Reads unseen CLI notifications -- Calls engine.invalidate(entityId) if needed -- Sends entity:changed IPC event to renderer -- Marks rows as seen -- Prunes: >1h processed, >24h unprocessed -- ═══════════════════════════════════════════════════════════════ -- PART 3: SIDE-EFFECT SUMMARY TABLE -- ═══════════════════════════════════════════════════════════════ -- Operation | File Write | FTS | Links | Thumbs | Sidecar | Embed | JSON Meta -- -------------------|--------------|------|-------|--------|---------|-------|---------- -- createPost | no (draft) | yes | no | no | no | yes | no -- updatePost | rename only* | yes | if Δ | no | no | yes | no -- publishPost | .md + trans | yes | yes | no | no | yes | no -- deletePost | delete .md | del | del | no | Δ media | del | no -- importMedia | copy file | yes | no | async | write | no | no -- updateMedia | no | yes | no | no | rewrite | no | no -- replaceMediaFile | overwrite | no | no | regen | no | no | no -- deleteMedia | delete all | del | no | del | del all | no | no -- createTemplate | .liquid | no | no | no | no | no | no -- updateTemplate | rewrite | no | no | no | no | no | no -- deleteTemplate | delete+casc | no | no | no | no | no | no -- deleteTag | sync posts | no | no | no | no | no | tags.json -- renameTag | sync posts | no | no | no | no | no | tags.json -- mergeTags | sync posts | no | no | no | no | no | tags.json -- updateMetadata | no | no | no | no | no | no | *.json -- addCategory | no | no | no | no | no | no | *.json -- * updatePost rewrites file only when templateSlug changes on published post -- ═══════════════════════════════════════════════════════════════ -- PART 4: ACTION PATTERNS AND CHAINS -- ═══════════════════════════════════════════════════════════════ -- Cross-cutting patterns for AI operations, auto-translation, -- drag-and-drop chains, confirmation dialogs, and offline gating. -- ─── 4.1 AI operation gating ─────────────────────────────── invariant AIOperationGating { -- All AI operations check airplane/offline mode before executing. -- If offline mode enabled: -- If local provider configured (Ollama/LM Studio): route to local model -- Else: show toast "AI unavailable in offline mode", abort operation -- If online: use configured provider endpoint -- Two model categories: -- Title model: text analysis (suggestions, translation, language detect) -- Image model: vision-capable (image analysis only) -- Model selection: per-operation default from settings, -- overrideable per-conversation in chat panel only } -- ─── 4.2 AI suggestions pattern ──────────────────────────── -- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis. -- See editors.allium AISuggestionsModal value type. rule AISuggestionFlow { when: AISuggestionRequested(entity_type, entity_id) -- 1. Check AIOperationGating (abort if offline and no local model) -- 2. Send request to appropriate model: -- Posts: title model, input = title + excerpt + first 2000 chars -- Media: image model, input = 448x448 AI thumbnail JPEG -- 3. Parse response into per-field suggestions -- 4. Open AISuggestionsModal: -- Each field: label, current value, suggested value, accept checkbox -- Accept checkboxes default to true -- Special case: post slug locked (no checkbox) if ever published -- 5. On Confirm: apply only accepted fields to entity -- Posts: triggers auto-save (3s timer reset) -- Media: triggers explicit save -- 6. On Cancel: discard all suggestions, no changes } -- ─── 4.3 Auto-translation chain ──────────────────────────── rule AutoTranslationChain { when: PostSaved(post_id) -- Gate: AIOperationGating + post.doNotTranslate must be false -- Triggered after any post save (auto-save, manual Ctrl+S, or unmount) -- For each configured blogLanguage missing a translation for this post: -- 1. Enqueue background task: translate metadata (title, excerpt) -- via title model -- 2. Enqueue background task: translate content (full markdown) -- via title model -- 3. Create/update translation record in DB -- Tasks: sequential per language, parallel across languages -- Progress visible in Tasks panel } rule MediaMetadataTranslationCascade { when: PostAutoTranslateCompleted(post_id, language) -- After a post translation completes for a given language: -- For each media item linked to this post: -- If media has source language set -- and no translation exists for {language}: -- Enqueue background task: translate media metadata -- (title, alt, caption) via title model -- Creates translated sidecar file: {path}.{lang}.meta } -- ─── 4.4 Drag-and-drop image chain ───────────────────────── -- Full chain when image file is dropped on post editor body. -- See editors.allium PostDragDropImage for trigger rule. -- Synchronous steps (user waits): -- 1. importMedia(file) -> new media record + file copy + base sidecar -- 2. generateThumbnails(media) -> async start (small/medium/large/ai) -- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds -- 4. insertMarkdownImage(cursor) -> insert ![](bds-media://id) at cursor -- Background steps (non-blocking, results auto-applied): -- 5. If AI available: aiImageAnalysis(media) -- Uses image model on 448x448 AI thumbnail -- Results auto-applied to media metadata (NO modal for drag-drop, -- unlike manual Quick Action which shows AISuggestionsModal) -- Triggers sidecar rewrite -- 6. If auto-translate enabled (post.doNotTranslate=false): -- For each blogLanguage: translateMediaMetadata(media, lang) -- Creates translated sidecar files -- ─── 4.5 Confirmation dialog patterns ────────────────────── -- Four distinct patterns used across the application: -- Pattern 1: Native confirm dialog (rfd crate) -- Simple yes/no system dialog, no custom UI -- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist) -- Pattern 2: ConfirmDeleteModal (custom modal with reference info) -- Shows entity name, reference counts, linked entity list -- Two buttons: Cancel, Delete (destructive red style) -- Used by: MediaDelete (shows linked posts), TagDelete (shows post count) -- Pattern 3: ConfirmDialog (custom modal for non-delete confirmations) -- Shows description of action and consequences -- Two buttons: Cancel, Confirm -- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.") -- Pattern 4: No confirmation (immediate execution) -- Action executes on click, no dialog -- Used by: all Rebuild operations, ScriptDelete, MenuItemDelete, -- MetadataDiff per-field sync, ImportExecute, MediaTranslationDelete, -- MediaUnlink -- ─── 4.6 Keyboard shortcut map ───────────────────────────── -- All shortcuts use Cmd on macOS, Ctrl on other platforms. -- Global: -- Ctrl/Cmd+B: toggle sidebar visibility -- Ctrl/Cmd+W: close active tab -- Post editor: -- Ctrl/Cmd+S: save post immediately (resets auto-save timer) -- Ctrl/Cmd+K: open InsertModal (insert post link) -- Sidebar lists: -- Enter: open selected item as pinned tab -- Space: open selected item as preview/transient tab