diff --git a/specs/bds.allium b/specs/bds.allium index f083e12..7156aab 100644 --- a/specs/bds.allium +++ b/specs/bds.allium @@ -31,6 +31,7 @@ use "./layout.allium" as layout -- App shell, activity bar, status b use "./tabs.allium" as tabs -- Tab system, editor routing, preview/pin use "./sidebar_views.allium" as sidebar -- 10 sidebar views, content, behaviour use "./editors.allium" as editors -- Editor view layouts, dashboard, chat panel +use "./flows.allium" as flows -- UI/UX flows, save side-effects, event propagation -- Integration use "./git.allium" as git -- Git operations, LFS, reconciliation diff --git a/specs/flows.allium b/specs/flows.allium new file mode 100644 index 0000000..4a1a28a --- /dev/null +++ b/specs/flows.allium @@ -0,0 +1,455 @@ +-- 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 + +-- ═══════════════════════════════════════════════════════════════ +-- 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