diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium new file mode 100644 index 0000000..4ce9d00 --- /dev/null +++ b/specs/action_patterns.allium @@ -0,0 +1,127 @@ +-- allium: 1 +-- bDS Action Patterns and Chains +-- Scope: cross-cutting (all waves) +-- Distilled from: PostEditor.tsx, MediaEditor.tsx, appStore.ts + +-- Cross-cutting patterns for AI operations, auto-translation, +-- drag-and-drop chains, and confirmation dialogs. + +use "./post.allium" as post +use "./media.allium" as media + +-- ─── External surfaces ────────────────────────────────────── + +surface UserAction { + provides: AISuggestionRequested(entity_type, entity_id) + provides: PostSaved(post_id) + provides: PostAutoTranslateCompleted(post_id, language) +} + +-- ─── 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 +} + +-- ─── AI suggestions pattern ──────────────────────────── + +-- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis. +-- See modals.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 +} + +-- ─── 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 +} + +-- ─── Drag-and-drop image chain ───────────────────────── + +-- Full chain when image file is dropped on post editor body. +-- See editor_post.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 + +-- ─── 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 diff --git a/specs/bds.allium b/specs/bds.allium index 7156aab..61064e6 100644 --- a/specs/bds.allium +++ b/specs/bds.allium @@ -30,8 +30,20 @@ use "./i18n.allium" as i18n -- Split localization (UI vs content use "./layout.allium" as layout -- App shell, activity bar, status bar, panels 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 +use "./modals.allium" as modals -- Shared modals (AI suggestions, confirm, gallery) +use "./editor_post.allium" as editor_post -- Post editor view and actions +use "./editor_media.allium" as editor_media -- Media editor view and actions +use "./editor_settings.allium" as editor_settings -- Settings + style views +use "./editor_tags.allium" as editor_tags -- Tags view and colour picker +use "./editor_chat.allium" as editor_chat -- Chat panel and model selector +use "./editor_script.allium" as editor_script -- Script editor +use "./editor_template.allium" as editor_template -- Template editor +use "./editor_misc.allium" as editor_misc -- Dashboard, menu, metadata diff, git diff, etc. + +-- Flows and side-effects +use "./ui_data_flow.allium" as ui_data_flow -- Sidebar/editor/tab reactive coordination +use "./engine_side_effects.allium" as engine_side_effects -- CRUD side-effect chains +use "./action_patterns.allium" as action_patterns -- AI gating, translation chains, confirmations -- Integration use "./git.allium" as git -- Git operations, LFS, reconciliation diff --git a/specs/editor_chat.allium b/specs/editor_chat.allium new file mode 100644 index 0000000..c20b7f8 --- /dev/null +++ b/specs/editor_chat.allium @@ -0,0 +1,88 @@ +-- allium: 1 +-- bDS Chat Panel +-- Scope: UI content area — AI chat surface +-- Distilled from: ChatPanel.tsx + +-- Describes the layout and behaviour of the chat panel. + +-- ─── Chat panel ─────────────────────────────────────────────── + +-- Three-region vertical layout: header, message area, input area. + +-- API Key Required screen (shown when no API key): +-- Key icon, title, description, "Open Settings" button + +-- Header: +-- Left: conversation title (or "New Chat"), CSS ellipsis on overflow +-- Right: model selector button + dropdown. Groups models by provider. + +-- Message area: +-- Welcome screen (no messages, not streaming): robot icon, tips +-- Message list: each has avatar (user=person, assistant=robot), role label, text +-- User messages: plain text +-- Assistant messages: rendered as GFM Markdown +-- External images blocked (CSP), shown as links +-- Tool markers: checkmark/dot, tool name, truncated args (30 char strings) +-- Streaming: accumulating markdown + tool markers, thinking dots animation +-- Auto-scrolls to bottom on new messages + +-- Input area: +-- Abort/Stop button (only during streaming, square stop icon) +-- Auto-growing textarea (max 200px). Enter sends, Shift+Enter newline +-- Send button (up-arrow icon), disabled when empty or streaming + +-- Token usage tracked per conversation (displayed in status bar, not chat panel). +-- Per-conversation model override via header selector. + +config { + chat_tool_args_max_length: Integer = 30 + chat_input_max_height: Integer = 200 +} + +-- ─── Model selector dropdown ────────────────────────────────── + +value ModelSelectorDropdown { + -- Dropdown in chat panel header for per-conversation model override. + -- Groups models by provider using section headers. + -- Each entry: model display name. + -- Expandable details per model: context window, max output tokens, pricing. + -- Selected model persisted with conversation record. + -- Changing model mid-conversation applies to subsequent messages only. + groups: List + selected_model_id: String? +} + +value ModelProviderGroup { + provider_name: String -- e.g. "OpenAI", "Ollama", "LM Studio" + models: List +} + +value ModelEntry { + model_id: String + display_name: String + context_window: Integer + max_output_tokens: Integer +} + +-- ─── Chat panel actions ───────────────────────────────────── + +-- Model selector: +-- Dropdown in header, groups models by provider (optgroup) +-- Selection is per-conversation override, persisted with conversation +-- Changing model mid-conversation applies to subsequent messages only + +-- Streaming behaviour: +-- Messages accumulate in real-time (markdown rendered incrementally) +-- Tool call markers: checkmark icon (done) or dot (in-progress), +-- tool name, args truncated to 30 chars for string values +-- Thinking animation (three dots) during LLM processing + +-- Stop/Abort: +-- Square stop icon button, visible only during active streaming +-- Stops current generation, partial message preserved as-is + +-- Assistant action dispatch: +-- Assistant tool calls can trigger navigation actions: +-- open_post(id), open_media(id), open_settings(), etc. +-- Actions dispatched through store, same as user clicks +-- Navigation actions open tabs with pin intent diff --git a/specs/editor_media.allium b/specs/editor_media.allium new file mode 100644 index 0000000..889d015 --- /dev/null +++ b/specs/editor_media.allium @@ -0,0 +1,188 @@ +-- allium: 1 +-- bDS Media Editor View +-- Scope: UI content area — media editing surface +-- Distilled from: MediaEditor.tsx + +-- Describes the layout and behaviour of the media editor rendered in +-- the main content area when a media tab is active. + +use "./media.allium" as media +use "./i18n.allium" as i18n + +-- ─── External surfaces ────────────────────────────────────── + +surface User { + provides: MediaAIImageAnalysisRequested(media_id) + provides: MediaDetectLanguageRequested(media_id) + provides: MediaTranslateMetadataRequested(media_id, target_language) + provides: MediaReplaceFileRequested(media_id) + provides: MediaDeleteRequested(media_id) + provides: MediaLinkToPostRequested(media_id) + provides: MediaTranslationEditClicked(media_id, language) + provides: MediaTranslationRefreshClicked(media_id, language) + provides: MediaTranslationDeleteClicked(media_id, language) +} + +-- ─── Media editor ───────────────────────────────────────────── + +-- Single column: preview area on top, metadata form below. + +-- Header bar: +-- Media display name +-- Actions: Quick Actions dropdown, Replace File, Save, Delete +-- Quick Actions: +-- AI Image Analysis (image/* types only) - suggests title, alt, caption +-- Detect Language (from metadata text) +-- Translate Metadata (opens translation target modal) + +-- Preview area: +-- Images: rendered via bds-media:// protocol with cache-busting timestamp +-- Non-images: SVG file icon placeholder + original filename text + +-- Metadata form fields: +-- File Name (read-only), MIME Type (read-only), +-- Size (formatted as KB) + Dimensions (W x H, only if width/height exist), +-- Title (editable), Alt Text (editable), Caption (textarea 3 rows), +-- Tags (comma-separated text input), Author, Language (select) + +-- Translations section (shown only when language is set): +-- List of existing translations: flag + language name + title +-- Per-translation: click to edit, refresh button, delete button +-- "No translations" message when empty + +-- Linked Posts section: +-- "Link to Post" button opens post picker overlay +-- Post picker: search input, up to 10 unlinked posts, "and N more" if >10 +-- List of currently linked posts (clickable -> navigates), unlink button +-- "Not linked to any posts" when empty + +value MediaEditorView { + media_id: String + is_image: Boolean + file_name: String -- originalName, read-only + mime_type: String + file_size: String -- formatted + dimensions: String? -- "W x H" if available + title: String? + alt_text: String? + caption: String? + tags: String? -- comma-separated + author: String? + language: String? + translations: List + linked_posts: List +} + +value MediaTranslationItem { + language: String + title: String? +} + +value LinkedPostItem { + post_id: String + title: String +} + +-- No auto-save; explicit Save button required. +-- Replace File opens a native file dialog. +-- Delete shows confirmation with references (linked posts). + +-- ─── Post picker overlay ──────────────────────────────────── + +value PostPickerOverlay { + -- Inline overlay positioned near "Link to Post" button (media editor). + -- Not a modal — positioned as dropdown below trigger button. + -- Search input filtering posts by title (only unlinked posts shown). + -- Up to 10 results displayed. "and N more" text if total exceeds 10. + -- Click result: links media to selected post, closes overlay. + search_query: String + results: List + overflow_count: Integer? -- shown as "and N more" if > 0 +} + +value PostPickerResult { + post_id: String + title: String +} + +-- ─── Media editor actions ───────────────────────────────────── + +rule MediaAIImageAnalysis { + when: MediaAIImageAnalysisRequested(media_id) + -- Gate: airplane mode check (see action_patterns.allium AIOperationGating) + -- Only available for image/* MIME types (button hidden for non-images) + -- Uses image analysis model (vision-capable, not title model) + -- Input: AI-optimized JPEG thumbnail (448x448, generated on import) + -- Response: suggested title, alt text, caption + -- Opens AISuggestionsModal with 3 fields (title, alt, caption) + -- On confirm: applies checked fields, triggers explicit save +} + +rule MediaDetectLanguage { + when: MediaDetectLanguageRequested(media_id) + -- Gate: airplane mode check + -- Input: concatenation of title + alt + caption text + -- Response: detected language code + -- Immediately persists to media record (no modal, no confirmation) + -- Triggers sidecar rewrite +} + +rule MediaTranslateMetadata { + when: MediaTranslateMetadataRequested(media_id, target_language) + -- Gate: airplane mode check + -- Opens language picker modal (same pattern as post translate) + -- Two-step process: + -- 1. If source language not set: detect it first (auto-persist) + -- 2. Translate title, alt, caption to target language via title model + -- Creates/updates media translation record + -- Writes translated sidecar file: {path}.{lang}.meta +} + +rule MediaReplaceFile { + when: MediaReplaceFileRequested(media_id) + -- Opens native file dialog (no MIME type filter) + -- Copies selected file over existing media file path + -- If image: regenerates thumbnails synchronously (awaited) + -- Preview area updates with cache-busting timestamp query param +} + +rule MediaDeleteAction { + when: MediaDeleteRequested(media_id) + -- Opens ConfirmDeleteModal (custom modal, not native dialog) + -- Shows: media display name, linked posts count and list + -- Two buttons: Cancel, Delete (destructive red style) + -- On confirm: deletes file, sidecar, thumbnails, all translations, + -- post-media links, FTS index entry + -- Closes media tab, sidebar removes item + -- See engine_side_effects.allium DeleteMediaSideEffects +} + +rule MediaLinkToPost { + when: MediaLinkToPostRequested(media_id) + -- Opens inline post picker overlay (not a modal, positioned near button) + -- Search input filtering unlinked posts by title + -- Up to 10 results shown, "and N more" text if results exceed 10 + -- Click links media to selected post (updates sidecar linkedPostIds) + -- Linked posts list refreshes immediately +} + +rule MediaTranslationEdit { + when: MediaTranslationEditClicked(media_id, language) + -- Loads translation fields inline (title, alt, caption) for that language + -- Edit in place, save persists to translated sidecar {path}.{lang}.meta +} + +rule MediaTranslationRefresh { + when: MediaTranslationRefreshClicked(media_id, language) + -- Gate: airplane mode check + -- Re-translates from source language to target via title model + -- Overwrites existing translation fields + -- Rewrites translated sidecar file +} + +rule MediaTranslationDelete { + when: MediaTranslationDeleteClicked(media_id, language) + -- Deletes translation record from DB + -- Deletes translated sidecar file: {path}.{lang}.meta + -- No confirmation dialog +} diff --git a/specs/editor_misc.allium b/specs/editor_misc.allium new file mode 100644 index 0000000..04fff6d --- /dev/null +++ b/specs/editor_misc.allium @@ -0,0 +1,247 @@ +-- allium: 1 +-- bDS Miscellaneous Editor Views +-- Scope: UI content area — dashboard, menu editor, metadata diff, git diff, +-- documentation, validation, find duplicates, import analysis +-- Distilled from: Editor.tsx (Dashboard), MenuEditorView.tsx, +-- MetadataDiffPanel.tsx, ImportAnalysisView.tsx + +-- Describes the layout and behaviour of smaller editor views that don't +-- warrant their own spec file. + +use "./tabs.allium" as tabs + +-- ─── External surfaces ────────────────────────────────────── + +surface User { + provides: ImportAnalyzeRequested(definition_id, file_path) + provides: ImportExecuteRequested(definition_id) +} + +-- ─── Dashboard (no tab active) ─────────────────────────────── + +-- Shown as default/welcome view when no entity tab is active. +-- Single centered column. + +value Dashboard { + title: String + subtitle: String + stats: DashboardStats + timeline: DashboardTimeline + tag_cloud: DashboardTagCloud + category_cloud: DashboardCategoryCloud + recent_posts: List +} + +value DashboardStats { + -- Three stat cards side by side: + total_posts: Integer + published_count: Integer + draft_count: Integer + archived_count: Integer -- shown only if > 0 + media_count: Integer + image_count: Integer + total_media_size: String -- formatted B/KB/MB/GB + tag_count: Integer + category_count: Integer +} + +value DashboardTimeline { + -- Bar chart of posts over time + -- Last 12 months that have data + -- Each bar: count label on top, month abbreviation + year below + -- Bar height proportional to max count + months: List +} + +value DashboardTimelineMonth { + label: String -- month abbreviation + year: Integer + count: Integer +} + +value DashboardTagCloud { + -- Up to 40 tags, sorted alphabetically + -- Font size scaled 11px-22px based on post count + -- Tags with colours get coloured background with contrast text + -- Hover title shows post count + -- "and N more" text if > 40 tags + tags: List + overflow_count: Integer? +} + +value DashboardTag { + name: String + count: Integer + color: String? +} + +value DashboardCategoryCloud { + -- All categories as badge-like tags + -- Each shows category name + count + categories: List +} + +value DashboardCategory { + name: String + count: Integer +} + +value DashboardRecentPost { + -- Last 5 posts by updatedAt descending + -- Each row: title (or "Untitled"), status badge (draft/published), date + -- Single-click: preview tab, double-click: pin tab + post_id: String + title: String + status: String + date: String +} + +config { + dashboard_max_tags: Integer = 40 + dashboard_tag_min_font: Integer = 11 + dashboard_tag_max_font: Integer = 22 + dashboard_recent_count: Integer = 5 + dashboard_timeline_months: Integer = 12 +} + +-- ─── Menu editor view ──────────────────────────────────────── + +-- Visual editor for the OPML navigation menu (meta/menu.opml). +-- See menu.allium for data model. + +-- Layout: toolbar at top, tree view of menu items below. +-- Each item row: drag handle, icon, label, type badge, action buttons. +-- Nested items indented to show hierarchy. + +-- ─── Menu editor actions ──────────────────────────────────── + +-- Add item (toolbar buttons): +-- Page entry: opens lazy-loaded page picker (posts with "page" category) +-- Category archive entry: opens lazy-loaded category picker +-- Submenu: creates empty container node for nesting children +-- Home entry: always available, maximum one allowed + +-- Save: serializes tree to OPML 2.0, writes meta/menu.opml + +-- Move operations (per-item buttons): +-- Up/Down: reorder within same nesting level +-- Indent: nest under previous sibling (becomes child) +-- Unindent: move to parent's level (becomes next sibling of parent) +-- Home item: protected — cannot be moved or deleted + +-- Drag-and-drop reorder: +-- Drag handle on each item +-- Auto-expand collapsed submenus on hover (450ms delay) +-- Drop indicators show target position and nesting level + +-- Delete: removes item and all children, no confirmation dialog +-- Exception: home item cannot be deleted (button hidden) + +-- ─── Metadata diff view ────────────────────────────────────── + +-- Shows DB vs filesystem differences for all entity types. +-- See metadata_diff.allium for diff field definitions. + +-- Layout: scan button at top, results grouped by entity type below. +-- Each diff entry: entity name, field name, DB value, file value, +-- two sync buttons (DB->File, File->DB). + +-- ─── Metadata diff actions ────────────────────────────────── + +-- Scan: runs 4 parallel scans (posts, media, scripts, templates) +-- Compares DB records against filesystem files field by field +-- Results grouped by entity type, sorted by entity name +-- Shows count of differences per entity type in section header + +-- Per-field sync: +-- DB -> File button: overwrites file field with DB value +-- File -> DB button: overwrites DB field with file value +-- No confirmation dialog for individual field syncs +-- Button disappears after successful sync (field now matches) + +-- Orphan file import: +-- Files on disk with no matching DB record shown separately +-- "Import" action creates DB record from file metadata +-- Appears at bottom of each entity type section + +-- ─── Git diff view ──────────────────────────────────────────── + +-- Renders diff for a file (working tree vs HEAD) or a commit. +-- File diff: id = "git-diff:{filePath}" +-- Commit diff: id = "git-diff:commit:{commitHash}" +-- Supports inline and side-by-side diff display modes (from editor settings). +-- No actions beyond viewing — changes are managed via git sidebar. + +-- ─── Documentation views ───────────────────────────────────── + +-- documentation: renders DOCUMENTATION.md as styled HTML +-- api_documentation: renders API.md as styled HTML + +-- ─── Validation views ──────────────────────────────────────── + +-- site_validation: checks generated site for broken links, missing assets +-- translation_validation: checks translation completeness across languages + +-- ─── Validation view actions ──────────────────────────────── + +-- Site validation: +-- Scans generated HTML for broken internal links, missing assets +-- Results: list of issues with file path + line + description +-- No auto-fix actions — informational only + +-- Translation validation: +-- Checks translation completeness across configured blog languages +-- Results: matrix of posts x languages, missing translations highlighted +-- No auto-fix actions — informational only + +-- ─── Find duplicates view ──────────────────────────────────── + +-- Uses embedding vectors to find semantically similar posts. +-- Shows candidate pairs with similarity score. +-- See embedding.allium for vector details. + +-- ─── Find duplicates actions ──────────────────────────────── + +-- Uses HNSW index of post embedding vectors for similarity search +-- Shows candidate pairs: post A title, post B title, score (0.0-1.0) +-- Click on either post title opens it in a tab (preview intent) +-- No auto-merge or auto-delete actions + +-- ─── Import analysis view ─────────────────────────────────── + +-- Editor for WXR (WordPress eXtended RSS) import definitions. +-- Keyed by import definition ID. Opened as always-pinned tab. +-- Workflow: Select File -> Analyze -> Resolve Conflicts -> Execute + +-- Layout: header (definition name), file selector, analysis results, +-- conflict resolution panel, taxonomy mapping, execute button. + +-- ─── Import analysis actions ──────────────────────────────── + +rule ImportSelectAndAnalyze { + when: ImportAnalyzeRequested(definition_id, file_path) + -- Parses WXR XML file + -- Extracts: posts, pages, media, tags, categories, authors + -- Shows summary counts per entity type + -- Identifies conflicts: duplicate slugs, existing categories/tags +} + +-- Conflict resolution: +-- Per-item dropdown: Import (create new), Skip (ignore), Merge (combine) +-- Default: Import for new items, Skip for existing matches + +-- Taxonomy mapping: +-- Source categories/tags mapped to existing project categories/tags +-- Dropdown per source taxonomy term -> target term or "Create New" +-- AI taxonomy analysis (optional): +-- Gate: airplane mode check +-- Suggests mappings based on name similarity via title model + +rule ImportExecute { + when: ImportExecuteRequested(definition_id) + -- No confirmation dialog — executes immediately + -- Processes items per conflict resolution settings + -- Creates posts, media, tags, categories as needed + -- Summary with counts shown in import view on completion + -- Created entities appear in sidebar immediately (store updated) +} diff --git a/specs/editor_post.allium b/specs/editor_post.allium new file mode 100644 index 0000000..f18bb63 --- /dev/null +++ b/specs/editor_post.allium @@ -0,0 +1,243 @@ +-- allium: 1 +-- bDS Post Editor View +-- Scope: UI content area — post editing surface +-- Distilled from: PostEditor.tsx + +-- Describes the layout and behaviour of the post editor rendered in +-- the main content area when a post tab is active. +-- Tab routing is in tabs.allium. Sidebar navigation is in sidebar_views.allium. + +use "./tabs.allium" as tabs +use "./post.allium" as post +use "./i18n.allium" as i18n + +-- ─── External surfaces ────────────────────────────────────── + +surface User { + provides: PostAIAnalysisRequested(post_id) + provides: PostTranslateRequested(post_id, target_language) + provides: PostSaved(post_id) + provides: PostPublishRequested(post_id) + provides: PostDiscardRequested(post_id) + provides: PostDeleteRequested(post_id) + provides: PostInsertLinkRequested(post_id) + provides: PostInsertMediaRequested(post_id) + provides: PostGalleryRequested(post_id) + provides: ImageDroppedOnEditor(post_id, file_path) + provides: PostLanguageDetectRequested(post_id) +} + +-- ─── Post editor ────────────────────────────────────────────── + +-- Single vertical column. Three regions: header, body, footer. + +-- Header bar: +-- Left: post title (or "Untitled") with dirty indicator dot +-- Right: status badge, auto-save indicator, Quick Actions dropdown, Publish/Delete buttons +-- Quick Actions: +-- AI Analysis (suggests title, excerpt, slug) +-- Translate Post (opens translation modal) + +-- Collapsible Metadata section (starts expanded when title is empty): +-- Two-column layout. Left column: metadata fields. Right column: linked media panel. +-- Left column fields: +-- Title (text input), Tags (autocomplete chip input), Author (text input), +-- Language (select + AI detect button), Do Not Translate (checkbox), +-- Slug (read-only), Categories (chip input), Template (select, if templates exist), +-- PostLinks (backlinks and outlinks) +-- Right column: +-- LinkedMediaPanel (media items linked to this post) + +-- Language flags bar (inline with metadata toggle): +-- Row of flag emoji buttons for canonical language + each translation +-- Styles: status class (draft/published), active indicator +-- Clicking switches editor content to that language's draft + +-- Collapsible Excerpt section: +-- Excerpt textarea (4 rows) + +-- Editor Body: +-- Toolbar: "Content" label | mode toggle (Visual/Markdown/Preview) | action buttons +-- Action buttons (markdown mode only): Gallery, Insert Post Link, Insert Media +-- Visual mode: rich-text WYSIWYG editor +-- Markdown mode: code editor with markdown-with-macros language +-- Highlights [[macro ...]] double-bracket syntax +-- Word wrap on, minimap off, 14px font +-- Preview mode: iframe showing rendered preview +-- Supports drag-and-drop of images (imports media, inserts markdown) + +-- Footer: +-- Three date stamps: Created, Updated, Published (published only if publishedAt exists) +-- Locale-formatted dates + +value PostEditorView { + post_id: String + header: PostEditorHeader + metadata_expanded: Boolean + excerpt_expanded: Boolean + editor_mode: String -- visual | markdown | preview +} + +value PostEditorHeader { + title: String -- post title or "Untitled" + is_dirty: Boolean + status: String -- draft | published | archived + is_auto_saving: Boolean +} + +config { + post_auto_save_delay: Integer = 3000 + -- milliseconds of idle before auto-save triggers +} + +invariant PostAutoSave { + -- Auto-saves after 3 seconds of idle in the editor + -- Also auto-saves on unmount/tab switch + -- Ctrl/Cmd+S triggers immediate save +} + +invariant PostDirtyTracking { + -- Compares canonical draft + translation drafts against saved state + -- Dirty indicator shown in header and tab bar +} + +-- ─── Post editor actions ──────────────────────────────────── + +rule PostAIAnalysis { + when: PostAIAnalysisRequested(post_id) + -- Gate: airplane mode check (see action_patterns.allium AIOperationGating) + -- Uses title model (not default chat model) + -- Input: post title + excerpt + content (first 2000 chars) + -- Response: suggested title, excerpt, slug + -- Opens AISuggestionsModal with 3 fields: + -- Each field: current value, suggested value, accept checkbox + -- Slug field locked (no accept checkbox) if post was ever published + -- On confirm: applies only checked fields, triggers auto-save +} + +rule PostTranslateAction { + when: PostTranslateRequested(post_id, target_language) + -- Gate: airplane mode check + -- Opens language picker modal: + -- Available target languages from project blogLanguages + -- Existing translations shown with status badge (draft/published) + -- Two sequential AI calls via title model: + -- 1. Translate metadata (title, excerpt) to target language + -- 2. Translate content (full markdown body) to target language + -- Creates/updates translation record in DB + -- If source post is published: transitions source to draft + -- (copies file content back to DB so it can be edited) +} + +rule PostAutoTranslateOnSave { + when: PostSaved(post_id) + -- Gate: airplane mode check + auto_translate not disabled (doNotTranslate=false) + -- For each configured blog language missing a translation: + -- Enqueue background translation task (title model) + -- Each task: translate metadata + content, create translation record + -- Cascades to linked media: for each linked media item, + -- translate media metadata for missing languages + -- See action_patterns.allium AutoTranslationChain for full chain +} + +rule PostPublishAction { + when: PostPublishRequested(post_id) + -- Implicit save first (awaited) if post is dirty + -- Then calls engine publish (see engine_side_effects.allium PublishPostSideEffects) + -- Also publishes all translations whose source language is published + -- UI updates: status badge -> published, sidebar section move +} + +rule PostDiscardChanges { + when: PostDiscardRequested(post_id) + -- Only available for published posts with pending draft changes + -- Native confirm dialog (rfd): "Discard changes to this post?" + -- On confirm: reads published version from .md file, + -- restores DB to published state (content=null, status=published) + -- Editor reloads with restored content +} + +rule PostDeleteAction { + when: PostDeleteRequested(post_id) + -- Native confirm dialog (rfd): "Delete this post?" + -- If published: also deletes .md file and all translation files + -- If never published: only deletes DB record + -- Removes from DB, closes tab, sidebar removes item + -- See engine_side_effects.allium DeletePostSideEffects +} + +rule PostInsertLink { + when: PostInsertLinkRequested(post_id) + -- Keyboard shortcut: Ctrl/Cmd+K + -- Opens InsertPostLinkModal with two tabs: Internal, External + -- Internal tab: + -- Search input (debounced, queries post titles) + -- Results list: title + status badge (draft/published) + -- If semantic similarity enabled: results ranked by similarity + -- Click inserts markdown link: [title](/YYYY/MM/DD/slug) + -- "Create Post" option at bottom of search results: + -- Creates new post with search query as title + -- Inserts link to newly created post + -- External tab: + -- URL input + optional display text input + -- Inserts: [text](url) or bare url if no display text +} + +rule PostInsertMedia { + when: PostInsertMediaRequested(post_id) + -- Opens InsertMediaModal (media search variant) + -- Search input, grid of media items with bds-thumb:// thumbnails + -- Click inserts markdown: + -- Images: ![alt](bds-media://id) + -- Non-images: [originalName](bds-media://id) +} + +rule PostGalleryAction { + when: PostGalleryRequested(post_id) + -- Opens gallery overlay showing all media linked to this post + -- Image grid with bds-thumb:// thumbnails + -- Click on image opens lightbox (full-size bds-media:// preview) + -- Lightbox: left/right arrow navigation, close button, ESC to close +} + +rule PostDragDropImage { + when: ImageDroppedOnEditor(post_id, file_path) + -- Chain of operations (see action_patterns.allium DragDropImageChain): + -- 1. Import media file -> media record + file copy + sidecar + -- 2. Generate thumbnails (async: small/medium/large/ai) + -- 3. Link media to post (update sidecar linkedPostIds) + -- 4. Insert markdown image at cursor: ![](bds-media://id) + -- 5. If AI available: AI image analysis (async, auto-applied, no modal) + -- 6. If auto-translate enabled: cascade translate media metadata + -- Steps 1-4 synchronous. Steps 5-6 background tasks. +} + +rule PostLanguageDetect { + when: PostLanguageDetectRequested(post_id) + -- Gate: airplane mode check + -- Sends content sample to title model + -- Auto-sets post language field (no modal) + -- Triggers auto-save +} + +-- Post editor mode switching: +-- Visual mode: rich-text WYSIWYG (TipTap in TS, not yet in Rust) +-- Markdown mode: code editor with markdown-with-macros highlighting +-- Preview mode: iframe with rendered HTML via preview server +-- Mode persists per-session, default from editor settings + +-- Language tab switching (translation editing): +-- Flag emoji buttons in metadata bar for canonical + each translation +-- Clicking a flag loads that language's draft content into editor +-- Each flag shows status (draft/published) via CSS class +-- Active flag highlighted + +-- LinkedMediaPanel (right column of metadata): +-- Shows media items linked to this post +-- Actions: +-- Import & Link: native file dialog, runs full import + link chain +-- Link Existing: opens media picker modal (search + grid) +-- Unlink: removes media-post link from sidecar (no confirmation) +-- Reorder: drag handle on each item +-- Click: opens media item in its own tab (preview intent) +-- Import & Link triggers: import -> thumbnail -> link -> optional AI analysis diff --git a/specs/editor_script.allium b/specs/editor_script.allium new file mode 100644 index 0000000..0564015 --- /dev/null +++ b/specs/editor_script.allium @@ -0,0 +1,50 @@ +-- allium: 1 +-- bDS Script Editor View +-- Scope: UI content area — script editing surface +-- Distilled from: ScriptEditor.tsx + +-- Describes the layout and behaviour of the script editor. +-- See script.allium for entity model. + +-- ─── External surfaces ────────────────────────────────────── + +surface User { + provides: ScriptSaveRequested(script_id) + provides: ScriptRunRequested(script_id) + provides: ScriptDeleteRequested(script_id) +} + +-- ─── Script editor view ────────────────────────────────────── + +-- Code editor for Lua scripts (macros, utilities, transforms). + +-- Layout: header bar, code editor, no preview. +-- Header: script title (editable), kind badge, enabled toggle. +-- Editor: code editor with Lua syntax highlighting. +-- Toolbar: Run button, Save button, Delete button, entrypoint selector. + +-- ─── Script editor actions ────────────────────────────────── + +rule ScriptSave { + when: ScriptSaveRequested(script_id) + -- Lua syntax check first (parse validation) + -- If syntax error: blocks save, shows error inline in editor gutter + -- If valid: bumps version, saves to DB + rewrites .lua file + -- See engine_side_effects.allium UpdateTemplateSideEffects (same pattern) +} + +rule ScriptRun { + when: ScriptRunRequested(script_id) + -- Executes script in Lua runtime + -- Calls configured entrypoint function (default: render) + -- stdout/stderr directed to Output panel tab + -- Output panel auto-opens if not already visible + -- Errors shown in Output panel with line numbers +} + +rule ScriptDelete { + when: ScriptDeleteRequested(script_id) + -- No confirmation dialog + -- Deletes DB record + .lua file on disk + -- Closes script tab, sidebar removes item +} diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium new file mode 100644 index 0000000..e1cbe6f --- /dev/null +++ b/specs/editor_settings.allium @@ -0,0 +1,138 @@ +-- allium: 1 +-- bDS Settings and Style Views +-- Scope: UI content area — settings + style editing surfaces +-- Distilled from: SettingsView.tsx, StyleView.tsx + +-- Describes the layout and behaviour of the settings and style views. + +-- ─── External surfaces ────────────────────────────────────── + +surface User { + provides: StyleThemeSelected(theme_name) + provides: StyleApplyRequested(theme_name) +} + +-- ─── Settings view ──────────────────────────────────────────── + +-- VS Code-style settings page with header + search bar + scrollable sections. +-- Search filters sections by keyword match. "No results" with clear button. + +-- 8 sections (style is a separate tab, not a settings section): + +-- 1. Project Settings: +-- Project Name (text), Project Description (textarea 3 rows), +-- Data Path (text + Browse + Reset), Public URL (url), +-- Main Language (select), Blog Languages (checkboxes, main disabled), +-- Default Author (text), Max Posts Per Page (number 1-500 default 50), +-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save + +-- 2. Editor Settings: +-- Default Editor Mode (select: WYSIWYG/Markdown/Preview), +-- Diff View Style (select: Inline/Side-by-side), +-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox) + +-- 3. Content Settings (Categories): +-- Table: Category | Title | Render in Lists | Show Titles | Post Template | List Template | Remove +-- Protected categories: article, aside, page, picture (cannot be deleted) +-- Add Category: text input + Add button. Reset to Defaults button + +-- 4. AI Assistant Settings: +-- API keys: OpenCode, Mistral (masked display + Change, or password input + Save) +-- Local providers: Ollama enable checkbox + capabilities table, +-- LM Studio enable checkbox + capabilities table +-- Offline mode: enable checkbox (disabled if no local providers), +-- selectors for chat/title/image models (local only) +-- Default Model (select with optgroup by provider + refresh), +-- shows: max output tokens, context window, pricing +-- Title Model (select), Image Analysis Model (vision-capable only) +-- System Prompt (textarea 12 rows + Save + Reset to Default) + +-- 5. Technology Settings: +-- Python Runtime Mode (select: Web Worker/Main Thread) +-- Semantic Similarity (checkbox) + +-- 6. Publishing Settings (SSH): +-- Info: SSH key auth only +-- SSH Mode (select: scp/rsync), SSH Host, SSH Username, SSH Remote Path +-- Save + Clear buttons + +-- 7. Data Maintenance: +-- Rebuild buttons (6): Posts from Files, Media from Files, Scripts from Files, +-- Templates from Files, Links, Regenerate Missing Thumbnails +-- Open Data Folder button + +-- 8. MCP Server Settings: +-- Status badge (port or "Not running") +-- Per-agent rows: Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI, +-- OpenCode, Mistral Vibe, OpenAI Codex — each with Add/Remove toggle + +-- ─── Settings view actions ────────────────────────────────── + +-- API key validation: +-- On Save: test API call to provider endpoint before persisting +-- On validation failure: toast error message, key not saved +-- On success: key saved, masked display shown + +-- Local provider enable/disable: +-- Enabling Ollama/LM Studio: fetches model list, shows capabilities table +-- Capabilities table columns: model name, chat, title, image (checkmarks) +-- Models auto-fetched on enable; manual refresh via Refresh Models button + +-- Model catalog refresh: +-- Refresh button next to Default Model selector +-- Fetches available models from all enabled providers +-- Updates optgroup dropdown with provider groupings +-- Per-model info: max output tokens, context window, pricing + +-- Rebuild operations (Data Maintenance section): +-- 6 buttons, each executes immediately (no confirmation dialog) +-- Runs as background task with progress visible in Tasks panel +-- On completion: wholesale replaces store data for that entity type +-- Sidebar and editors re-render with fresh data + +-- Category management (Content Settings): +-- Protected categories: article, aside, page, picture (Remove button disabled) +-- Add Category: text input + Add button, validates non-empty and unique +-- Per-category settings: editable inline in table row +-- "Reset to Defaults" restores default categories set + +-- MCP per-agent config: +-- Add/Remove toggle per agent row +-- Status badge: shows port number when running, "Not running" otherwise + +-- Bookmarklet (Project Settings): +-- Copy button copies bookmarklet JavaScript to clipboard +-- Bookmarklet uses project's publicUrl to construct POST endpoint + +-- ─── Style view ─────────────────────────────────────────────── + +-- Simple vertical layout: header, theme picker, controls row, preview. + +-- Theme Picker: grid of theme buttons (one per Pico CSS theme). +-- Each button: swatch with 3 colour tones (accent, light bg, dark bg), theme name. +-- Selected theme: highlighted with aria-pressed. + +-- Controls row: Preview Mode dropdown (Auto/Light/Dark), Apply Theme button. + +-- Preview: iframe of style preview page at 127.0.0.1:4123/__style-preview +-- with theme and mode query params. Updates live on selection change. + +-- Apply Theme persists to project metadata. + +-- ─── Style view actions ───────────────────────────────────── + +rule StyleThemeSelect { + when: StyleThemeSelected(theme_name) + -- Updates iframe preview immediately (query param change) + -- Does NOT persist until Apply is clicked +} + +rule StyleApplyTheme { + when: StyleApplyRequested(theme_name) + -- Persists picoTheme to project metadata (meta/project.json) + -- See engine_side_effects.allium UpdateProjectMetadataSideEffects +} + +-- Preview mode dropdown (Auto/Light/Dark): +-- Controls iframe query param, live update on change +-- Does not persist — local UI state only diff --git a/specs/editor_tags.allium b/specs/editor_tags.allium new file mode 100644 index 0000000..f8ea3ac --- /dev/null +++ b/specs/editor_tags.allium @@ -0,0 +1,65 @@ +-- allium: 1 +-- bDS Tags View +-- Scope: UI content area — tag management surface +-- Distilled from: TagsView.tsx + +-- Describes the layout and behaviour of the tags view. + +-- ─── Tags view ──────────────────────────────────────────────── + +-- Three sections controlled by sidebar TagsNav: + +-- 1. Cloud: tag cloud visualization. Tags sized by post count, +-- coloured by tag colour. Clickable to select for editing. + +-- 2. Manage (Create/Edit): tag name input, colour picker, template select. +-- Edit existing tag or create new. Delete button with confirmation. + +-- 3. Merge: source tag select, target tag select, Merge button. +-- Merges all posts from source tag into target, removes source. + +-- ─── Tags view actions ────────────────────────────────────── + +-- Cloud section: +-- Tags sized by post count (same 11-22px scaling as dashboard) +-- Tags with colours get coloured background +-- Click selects tag: switches to Manage section, loads tag data + +-- Manage section: +-- Create new: empty form, Save creates tag + writes tags.json +-- Edit existing: loads tag data into form, Save updates +-- Tag name input, colour picker, template select (optional) +-- Colour picker: 17 preset colour swatches + custom hex input field +-- Delete: opens ConfirmDialog showing post count +-- "This tag is used in N posts. Delete anyway?" +-- On confirm: background task removes tag from all posts, +-- rewrites published .md files, deletes tag, writes tags.json + +-- Merge section: +-- Source tag multi-select, target tag single-select +-- Merge button: opens ConfirmDialog +-- "Merge N tags into {target}? This cannot be undone." +-- On confirm: background task updates all affected posts, +-- rewrites published .md files, deletes source tags, writes tags.json + +-- Sync (maintenance action): +-- Rewrites tags.json from current DB state + +-- ─── Colour picker popover ────────────────────────────────── + +value ColourPickerPopover { + -- Inline popover positioned below tag colour field (tags view manage). + -- Not a modal — does not dim backdrop, no ESC/backdrop close. + -- Closes when clicking outside popover or selecting a colour. + -- Grid of 17 preset colour swatches (4x4 + 1). + -- Below grid: custom hex input field (#RRGGBB). + -- Selection is immediate — no confirm/cancel buttons. + -- Choosing a colour updates the tag form's colour field live. + presets: List -- 17 hex colour values + custom_hex: String? + selected: String? +} + +config { + colour_picker_preset_count: Integer = 17 +} diff --git a/specs/editor_template.allium b/specs/editor_template.allium new file mode 100644 index 0000000..7b1728f --- /dev/null +++ b/specs/editor_template.allium @@ -0,0 +1,46 @@ +-- allium: 1 +-- bDS Template Editor View +-- Scope: UI content area — template editing surface +-- Distilled from: TemplateEditor.tsx + +-- Describes the layout and behaviour of the template editor. +-- See template.allium for entity model and Liquid subset. + +-- ─── External surfaces ────────────────────────────────────── + +surface User { + provides: TemplateSaveRequested(template_id) + provides: TemplateDeleteRequested(template_id) +} + +-- ─── Template editor view ───────────────────────────────────── + +-- Code editor for Liquid templates (post, list, not_found, partial). + +-- Layout: header bar, code editor, preview pane. +-- Header: template title (editable), kind badge, enabled toggle, version display. +-- Editor: code editor with Liquid syntax highlighting. +-- Preview: rendered template output in iframe (uses sample post data). +-- Toolbar: Save button, Delete button. + +-- ─── Template editor actions ──────────────────────────────── + +rule TemplateSave { + when: TemplateSaveRequested(template_id) + -- Liquid validation first (parse check for syntax errors) + -- If invalid: blocks save, shows error inline in editor + -- If valid: bumps version, saves to DB + rewrites .liquid file + -- See engine_side_effects.allium UpdateTemplateSideEffects +} + +rule TemplateDelete { + when: TemplateDeleteRequested(template_id) + -- Checks for references: posts using this template, tags with postTemplateSlug + -- If references exist: native confirm dialog (rfd) + -- "This template is used by N posts and M tags. Force delete?" + -- Force delete: nulls templateSlug on referencing posts, + -- nulls postTemplateSlug on referencing tags + -- If no references: deletes without confirmation + -- Deletes DB record + .liquid file on disk + -- Closes template tab, sidebar removes item +} diff --git a/specs/editors.allium b/specs/editors.allium deleted file mode 100644 index 506d4c1..0000000 --- a/specs/editors.allium +++ /dev/null @@ -1,1142 +0,0 @@ --- allium: 1 --- bDS Editor Views --- Scope: UI content area (all waves + extensions) --- Distilled from: PostEditor.tsx, MediaEditor.tsx, Editor.tsx (Dashboard), --- SettingsView.tsx, StyleView.tsx, ChatPanel.tsx, TagsView, --- ScriptEditor.tsx, TemplateEditor.tsx, MenuEditorView.tsx, --- MetadataDiffPanel.tsx, ImportAnalysisView.tsx - --- Describes the layout and behaviour of each editor view rendered in --- the main content area when a tab is active (or dashboard when no tab). --- Tab routing is in tabs.allium. Sidebar navigation is in sidebar_views.allium. - -use "./tabs.allium" as tabs -use "./post.allium" as post -use "./media.allium" as media -use "./i18n.allium" as i18n - --- ─── External surfaces ────────────────────────────────────── - --- The user is the external actor who initiates all editor interactions --- via clicks, keyboard shortcuts, and drag-and-drop. This surface --- provides the triggers that editor action rules listen for. - -surface User { - provides: PostAIAnalysisRequested(post_id) - provides: PostTranslateRequested(post_id, target_language) - provides: PostSaved(post_id) - provides: PostPublishRequested(post_id) - provides: PostDiscardRequested(post_id) - provides: PostDeleteRequested(post_id) - provides: PostInsertLinkRequested(post_id) - provides: PostInsertMediaRequested(post_id) - provides: PostGalleryRequested(post_id) - provides: ImageDroppedOnEditor(post_id, file_path) - provides: PostLanguageDetectRequested(post_id) - provides: MediaAIImageAnalysisRequested(media_id) - provides: MediaDetectLanguageRequested(media_id) - provides: MediaTranslateMetadataRequested(media_id, target_language) - provides: MediaReplaceFileRequested(media_id) - provides: MediaDeleteRequested(media_id) - provides: MediaLinkToPostRequested(media_id) - provides: MediaTranslationEditClicked(media_id, language) - provides: MediaTranslationRefreshClicked(media_id, language) - provides: MediaTranslationDeleteClicked(media_id, language) - provides: StyleThemeSelected(theme_name) - provides: StyleApplyRequested(theme_name) - provides: ScriptSaveRequested(script_id) - provides: ScriptRunRequested(script_id) - provides: ScriptDeleteRequested(script_id) - provides: TemplateSaveRequested(template_id) - provides: TemplateDeleteRequested(template_id) - provides: ImportAnalyzeRequested(definition_id, file_path) - provides: ImportExecuteRequested(definition_id) -} - --- ─── Dashboard (no tab active) ─────────────────────────────── - --- Shown as default/welcome view when no entity tab is active. --- Single centered column. - -value Dashboard { - title: String - subtitle: String - stats: DashboardStats - timeline: DashboardTimeline - tag_cloud: DashboardTagCloud - category_cloud: DashboardCategoryCloud - recent_posts: List -} - -value DashboardStats { - -- Three stat cards side by side: - total_posts: Integer - published_count: Integer - draft_count: Integer - archived_count: Integer -- shown only if > 0 - media_count: Integer - image_count: Integer - total_media_size: String -- formatted B/KB/MB/GB - tag_count: Integer - category_count: Integer -} - -value DashboardTimeline { - -- Bar chart of posts over time - -- Last 12 months that have data - -- Each bar: count label on top, month abbreviation + year below - -- Bar height proportional to max count - months: List -} - -value DashboardTimelineMonth { - label: String -- month abbreviation - year: Integer - count: Integer -} - -value DashboardTagCloud { - -- Up to 40 tags, sorted alphabetically - -- Font size scaled 11px-22px based on post count - -- Tags with colours get coloured background with contrast text - -- Hover title shows post count - -- "and N more" text if > 40 tags - tags: List - overflow_count: Integer? -} - -value DashboardTag { - name: String - count: Integer - color: String? -} - -value DashboardCategoryCloud { - -- All categories as badge-like tags - -- Each shows category name + count - categories: List -} - -value DashboardCategory { - name: String - count: Integer -} - -value DashboardRecentPost { - -- Last 5 posts by updatedAt descending - -- Each row: title (or "Untitled"), status badge (draft/published), date - -- Single-click: preview tab, double-click: pin tab - post_id: String - title: String - status: String - date: String -} - -config { - dashboard_max_tags: Integer = 40 - dashboard_tag_min_font: Integer = 11 - dashboard_tag_max_font: Integer = 22 - dashboard_recent_count: Integer = 5 - dashboard_timeline_months: Integer = 12 -} - --- ─── Post editor ────────────────────────────────────────────── - --- Single vertical column. Three regions: header, body, footer. - --- Header bar: --- Left: post title (or "Untitled") with dirty indicator dot --- Right: status badge, auto-save indicator, Quick Actions dropdown, Publish/Delete buttons --- Quick Actions: --- AI Analysis (suggests title, excerpt, slug) --- Translate Post (opens translation modal) - --- Collapsible Metadata section (starts expanded when title is empty): --- Two-column layout. Left column: metadata fields. Right column: linked media panel. --- Left column fields: --- Title (text input), Tags (autocomplete chip input), Author (text input), --- Language (select + AI detect button), Do Not Translate (checkbox), --- Slug (read-only), Categories (chip input), Template (select, if templates exist), --- PostLinks (backlinks and outlinks) --- Right column: --- LinkedMediaPanel (media items linked to this post) - --- Language flags bar (inline with metadata toggle): --- Row of flag emoji buttons for canonical language + each translation --- Styles: status class (draft/published), active indicator --- Clicking switches editor content to that language's draft - --- Collapsible Excerpt section: --- Excerpt textarea (4 rows) - --- Editor Body: --- Toolbar: "Content" label | mode toggle (Visual/Markdown/Preview) | action buttons --- Action buttons (markdown mode only): Gallery, Insert Post Link, Insert Media --- Visual mode: rich-text WYSIWYG editor --- Markdown mode: code editor with markdown-with-macros language --- Highlights [[macro ...]] double-bracket syntax --- Word wrap on, minimap off, 14px font --- Preview mode: iframe showing rendered preview --- Supports drag-and-drop of images (imports media, inserts markdown) - --- Footer: --- Three date stamps: Created, Updated, Published (published only if publishedAt exists) --- Locale-formatted dates - --- Modals: --- InsertModal for link search (Ctrl+K) and media search --- AISuggestionsModal for AI-generated title/excerpt/slug --- Translation modal: select target language, shows existing status --- Lightbox for content images - -value PostEditorView { - post_id: String - header: PostEditorHeader - metadata_expanded: Boolean - excerpt_expanded: Boolean - editor_mode: String -- visual | markdown | preview -} - -value PostEditorHeader { - title: String -- post title or "Untitled" - is_dirty: Boolean - status: String -- draft | published | archived - is_auto_saving: Boolean -} - -config { - post_auto_save_delay: Integer = 3000 - -- milliseconds of idle before auto-save triggers -} - -invariant PostAutoSave { - -- Auto-saves after 3 seconds of idle in the editor - -- Also auto-saves on unmount/tab switch - -- Ctrl/Cmd+S triggers immediate save -} - -invariant PostDirtyTracking { - -- Compares canonical draft + translation drafts against saved state - -- Dirty indicator shown in header and tab bar -} - --- ─── Post editor actions ──────────────────────────────────── - --- Shared modal for presenting AI suggestions with per-field accept/reject. --- Used by PostAIAnalysis and MediaAIImageAnalysis. -value AISuggestionsModal { - fields: List - -- Layout: title bar ("AI Suggestions"), scrollable field list, button row - -- Each field rendered as a row: - -- Left: checkbox (accept/reject), label - -- Center: current value (read-only, muted), arrow, suggested value (highlighted) - -- Special: slug field checkbox disabled if post was ever published - -- Buttons: Cancel (secondary), Apply Selected (primary) - -- Cancel discards all; Apply writes only accepted fields to entity -} - -value AISuggestionField { - label: String - current_value: String - suggested_value: String - accepted: Boolean -- checkbox, default true - locked: Boolean -- if true, checkbox disabled (e.g. published slug) -} - --- ─── Modals and overlays ──────────────────────────────────── - --- All modals rendered as centered overlay with backdrop dimming. --- ESC key or backdrop click closes modal (cancel semantics). --- Overlays (PostPicker, ColourPicker) are positioned inline near trigger. - -value InsertPostLinkModal { - -- Two-tab modal opened by Ctrl/Cmd+K in post editor (markdown mode). - -- Tab 1 - Internal: - -- Search input (debounced 300ms, queries post titles) - -- Results list: post title + status badge (draft/published/archived) - -- If semantic similarity enabled: results ranked by vector similarity - -- Click result: inserts [title](/YYYY/MM/DD/slug) at cursor, closes modal - -- "Create Post" row at bottom of results: - -- Creates new post with search query as title, inserts link to it - -- Tab 2 - External: - -- URL input field (required) - -- Display text input field (optional) - -- Insert button: inserts [text](url) or bare url if no text, closes modal - active_tab: String -- internal | external - search_query: String - results: List -} - -value InsertLinkResult { - post_id: String - title: String - status: String -- draft | published | archived - canonical_url: String -- /YYYY/MM/DD/slug -} - -value InsertMediaModal { - -- Grid modal for inserting media references into post content. - -- Search input filtering by media title and original filename. - -- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below. - -- Click item: - -- Images: inserts ![alt](bds-media://id) at cursor - -- Non-images: inserts [originalName](bds-media://id) at cursor - -- Closes modal after insertion. - search_query: String - results: List -} - -value InsertMediaResult { - media_id: String - title: String - original_name: String - is_image: Boolean - thumbnail_url: String? -- bds-thumb:// for images, null for others -} - -value LanguagePickerModal { - -- Shown for Translate Post and Translate Media Metadata actions. - -- Lists all configured blogLanguages except source language. - -- Each row: flag emoji, language name, status badge if translation exists. - -- Existing translations show "(draft)" or "(published)" badge. - -- Click selects target language and initiates translation flow. - -- Cancel closes without action. - source_language: String - available_targets: List -} - -value LanguageTarget { - code: String - name: String - flag_emoji: String - has_existing_translation: Boolean - existing_status: String? -- draft | published, if translation exists -} - -value ConfirmDeleteModal { - -- Custom styled modal for destructive operations with reference info. - -- Used by: MediaDelete (shows linked posts), TagDelete (shows post count). - -- Layout: warning icon, title, entity name, reference section, buttons. - -- Reference section: "This item is referenced by:" + bulleted list. - -- Buttons: Cancel (secondary), Delete (destructive red). - entity_name: String - entity_type: String -- media | tag - reference_count: Integer - reference_list: List -- titles of referencing entities -} - -value ConfirmDialog { - -- Custom styled modal for non-delete confirmations. - -- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone."). - -- Layout: title, descriptive message, buttons. - -- Buttons: Cancel (secondary), Confirm (primary). - title: String - message: String -} - --- Native confirm dialogs (via rfd crate) are NOT modelled as values. --- They are simple yes/no system dialogs with a message string. --- Used by: PostDelete, PostDiscard, TemplateDelete (with references). - -value GalleryOverlay { - -- Full-screen overlay showing all media linked to a post. - -- Opened from Gallery button in post editor toolbar (markdown mode). - -- Image grid: bds-thumb:// thumbnails (medium 400px), 3-4 columns. - -- Click image: opens LightboxView for that image. - -- Close: X button or ESC key. - post_id: String - images: List -} - -value GalleryImage { - media_id: String - thumbnail_url: String -- bds-thumb://media_id - alt_text: String? -} - -value LightboxView { - -- Full-screen image viewer, sub-view of GalleryOverlay. - -- Shows single image at full resolution via bds-media:// protocol. - -- Navigation: left/right arrow buttons, keyboard left/right arrow keys. - -- Close: X button, ESC key, or click outside image area. - -- Header: image title or filename, index counter "3 of 12". - current_index: Integer - total_count: Integer - media_id: String - image_url: String -- bds-media://media_id - alt_text: String? -} - -value ColourPickerPopover { - -- Inline popover positioned below tag colour field (tags view manage). - -- Not a modal — does not dim backdrop, no ESC/backdrop close. - -- Closes when clicking outside popover or selecting a colour. - -- Grid of 17 preset colour swatches (4x4 + 1). - -- Below grid: custom hex input field (#RRGGBB). - -- Selection is immediate — no confirm/cancel buttons. - -- Choosing a colour updates the tag form's colour field live. - presets: List -- 17 hex colour values - custom_hex: String? - selected: String? -} - -config { - colour_picker_preset_count: Integer = 17 -} - -value PostPickerOverlay { - -- Inline overlay positioned near "Link to Post" button (media editor). - -- Not a modal — positioned as dropdown below trigger button. - -- Search input filtering posts by title (only unlinked posts shown). - -- Up to 10 results displayed. "and N more" text if total exceeds 10. - -- Click result: links media to selected post, closes overlay. - search_query: String - results: List - overflow_count: Integer? -- shown as "and N more" if > 0 -} - -value PostPickerResult { - post_id: String - title: String -} - -value ModelSelectorDropdown { - -- Dropdown in chat panel header for per-conversation model override. - -- Groups models by provider using section headers. - -- Each entry: model display name. - -- Expandable details per model: context window, max output tokens, pricing. - -- Selected model persisted with conversation record. - -- Changing model mid-conversation applies to subsequent messages only. - groups: List - selected_model_id: String? -} - -value ModelProviderGroup { - provider_name: String -- e.g. "OpenAI", "Ollama", "LM Studio" - models: List -} - -value ModelEntry { - model_id: String - display_name: String - context_window: Integer - max_output_tokens: Integer -} - -rule PostAIAnalysis { - when: PostAIAnalysisRequested(post_id) - -- Gate: airplane mode check (see flows.allium AIOperationGating) - -- Uses title model (not default chat model) - -- Input: post title + excerpt + content (first 2000 chars) - -- Response: suggested title, excerpt, slug - -- Opens AISuggestionsModal with 3 fields: - -- Each field: current value, suggested value, accept checkbox - -- Slug field locked (no accept checkbox) if post was ever published - -- On confirm: applies only checked fields, triggers auto-save -} - -rule PostTranslateAction { - when: PostTranslateRequested(post_id, target_language) - -- Gate: airplane mode check - -- Opens language picker modal: - -- Available target languages from project blogLanguages - -- Existing translations shown with status badge (draft/published) - -- Two sequential AI calls via title model: - -- 1. Translate metadata (title, excerpt) to target language - -- 2. Translate content (full markdown body) to target language - -- Creates/updates translation record in DB - -- If source post is published: transitions source to draft - -- (copies file content back to DB so it can be edited) -} - -rule PostAutoTranslateOnSave { - when: PostSaved(post_id) - -- Gate: airplane mode check + auto_translate not disabled (doNotTranslate=false) - -- For each configured blog language missing a translation: - -- Enqueue background translation task (title model) - -- Each task: translate metadata + content, create translation record - -- Cascades to linked media: for each linked media item, - -- translate media metadata for missing languages - -- See flows.allium AutoTranslationChain for full chain -} - -rule PostPublishAction { - when: PostPublishRequested(post_id) - -- Implicit save first (awaited) if post is dirty - -- Then calls engine publish (see flows.allium PublishPostSideEffects) - -- Also publishes all translations whose source language is published - -- UI updates: status badge -> published, sidebar section move -} - -rule PostDiscardChanges { - when: PostDiscardRequested(post_id) - -- Only available for published posts with pending draft changes - -- Native confirm dialog (rfd): "Discard changes to this post?" - -- On confirm: reads published version from .md file, - -- restores DB to published state (content=null, status=published) - -- Editor reloads with restored content -} - -rule PostDeleteAction { - when: PostDeleteRequested(post_id) - -- Native confirm dialog (rfd): "Delete this post?" - -- If published: also deletes .md file and all translation files - -- If never published: only deletes DB record - -- Removes from DB, closes tab, sidebar removes item - -- See flows.allium DeletePostSideEffects -} - -rule PostInsertLink { - when: PostInsertLinkRequested(post_id) - -- Keyboard shortcut: Ctrl/Cmd+K - -- Opens InsertModal with two tabs: Internal, External - -- Internal tab: - -- Search input (debounced, queries post titles) - -- Results list: title + status badge (draft/published) - -- If semantic similarity enabled: results ranked by similarity - -- Click inserts markdown link: [title](/YYYY/MM/DD/slug) - -- "Create Post" option at bottom of search results: - -- Creates new post with search query as title - -- Inserts link to newly created post - -- External tab: - -- URL input + optional display text input - -- Inserts: [text](url) or bare url if no display text -} - -rule PostInsertMedia { - when: PostInsertMediaRequested(post_id) - -- Opens InsertModal (media search variant) - -- Search input, grid of media items with bds-thumb:// thumbnails - -- Click inserts markdown: - -- Images: ![alt](bds-media://id) - -- Non-images: [originalName](bds-media://id) -} - -rule PostGalleryAction { - when: PostGalleryRequested(post_id) - -- Opens gallery overlay showing all media linked to this post - -- Image grid with bds-thumb:// thumbnails - -- Click on image opens lightbox (full-size bds-media:// preview) - -- Lightbox: left/right arrow navigation, close button, ESC to close -} - -rule PostDragDropImage { - when: ImageDroppedOnEditor(post_id, file_path) - -- Chain of operations (see flows.allium DragDropImageChain): - -- 1. Import media file -> media record + file copy + sidecar - -- 2. Generate thumbnails (async: small/medium/large/ai) - -- 3. Link media to post (update sidecar linkedPostIds) - -- 4. Insert markdown image at cursor: ![](bds-media://id) - -- 5. If AI available: AI image analysis (async, auto-applied, no modal) - -- 6. If auto-translate enabled: cascade translate media metadata - -- Steps 1-4 synchronous. Steps 5-6 background tasks. -} - -rule PostLanguageDetect { - when: PostLanguageDetectRequested(post_id) - -- Gate: airplane mode check - -- Sends content sample to title model - -- Auto-sets post language field (no modal) - -- Triggers auto-save -} - --- Post editor mode switching: --- Visual mode: rich-text WYSIWYG (TipTap in TS, not yet in Rust) --- Markdown mode: code editor with markdown-with-macros highlighting --- Preview mode: iframe with rendered HTML via preview server --- Mode persists per-session, default from editor settings - --- Language tab switching (translation editing): --- Flag emoji buttons in metadata bar for canonical + each translation --- Clicking a flag loads that language's draft content into editor --- Each flag shows status (draft/published) via CSS class --- Active flag highlighted - --- LinkedMediaPanel (right column of metadata): --- Shows media items linked to this post --- Actions: --- Import & Link: native file dialog, runs full import + link chain --- Link Existing: opens media picker modal (search + grid) --- Unlink: removes media-post link from sidecar (no confirmation) --- Reorder: drag handle on each item --- Click: opens media item in its own tab (preview intent) --- Import & Link triggers: import -> thumbnail -> link -> optional AI analysis - --- ─── Media editor ───────────────────────────────────────────── - --- Single column: preview area on top, metadata form below. - --- Header bar: --- Media display name --- Actions: Quick Actions dropdown, Replace File, Save, Delete --- Quick Actions: --- AI Image Analysis (image/* types only) - suggests title, alt, caption --- Detect Language (from metadata text) --- Translate Metadata (opens translation target modal) - --- Preview area: --- Images: rendered via bds-media:// protocol with cache-busting timestamp --- Non-images: SVG file icon placeholder + original filename text - --- Metadata form fields: --- File Name (read-only), MIME Type (read-only), --- Size (formatted as KB) + Dimensions (W x H, only if width/height exist), --- Title (editable), Alt Text (editable), Caption (textarea 3 rows), --- Tags (comma-separated text input), Author, Language (select) - --- Translations section (shown only when language is set): --- List of existing translations: flag + language name + title --- Per-translation: click to edit, refresh button, delete button --- "No translations" message when empty - --- Linked Posts section: --- "Link to Post" button opens post picker overlay --- Post picker: search input, up to 10 unlinked posts, "and N more" if >10 --- List of currently linked posts (clickable -> navigates), unlink button --- "Not linked to any posts" when empty - -value MediaEditorView { - media_id: String - is_image: Boolean - file_name: String -- originalName, read-only - mime_type: String - file_size: String -- formatted - dimensions: String? -- "W x H" if available - title: String? - alt_text: String? - caption: String? - tags: String? -- comma-separated - author: String? - language: String? - translations: List - linked_posts: List -} - -value MediaTranslationItem { - language: String - title: String? -} - -value LinkedPostItem { - post_id: String - title: String -} - --- No auto-save; explicit Save button required. --- Replace File opens a native file dialog. --- Delete shows confirmation with references (linked posts). - --- ─── Media editor actions ───────────────────────────────────── - -rule MediaAIImageAnalysis { - when: MediaAIImageAnalysisRequested(media_id) - -- Gate: airplane mode check (see flows.allium AIOperationGating) - -- Only available for image/* MIME types (button hidden for non-images) - -- Uses image analysis model (vision-capable, not title model) - -- Input: AI-optimized JPEG thumbnail (448x448, generated on import) - -- Response: suggested title, alt text, caption - -- Opens AISuggestionsModal with 3 fields (title, alt, caption) - -- On confirm: applies checked fields, triggers explicit save -} - -rule MediaDetectLanguage { - when: MediaDetectLanguageRequested(media_id) - -- Gate: airplane mode check - -- Input: concatenation of title + alt + caption text - -- Response: detected language code - -- Immediately persists to media record (no modal, no confirmation) - -- Triggers sidecar rewrite -} - -rule MediaTranslateMetadata { - when: MediaTranslateMetadataRequested(media_id, target_language) - -- Gate: airplane mode check - -- Opens language picker modal (same pattern as post translate) - -- Two-step process: - -- 1. If source language not set: detect it first (auto-persist) - -- 2. Translate title, alt, caption to target language via title model - -- Creates/updates media translation record - -- Writes translated sidecar file: {path}.{lang}.meta -} - -rule MediaReplaceFile { - when: MediaReplaceFileRequested(media_id) - -- Opens native file dialog (no MIME type filter) - -- Copies selected file over existing media file path - -- If image: regenerates thumbnails synchronously (awaited) - -- Preview area updates with cache-busting timestamp query param -} - -rule MediaDeleteAction { - when: MediaDeleteRequested(media_id) - -- Opens ConfirmDeleteModal (custom modal, not native dialog) - -- Shows: media display name, linked posts count and list - -- Two buttons: Cancel, Delete (destructive red style) - -- On confirm: deletes file, sidecar, thumbnails, all translations, - -- post-media links, FTS index entry - -- Closes media tab, sidebar removes item - -- See flows.allium DeleteMediaSideEffects -} - -rule MediaLinkToPost { - when: MediaLinkToPostRequested(media_id) - -- Opens inline post picker overlay (not a modal, positioned near button) - -- Search input filtering unlinked posts by title - -- Up to 10 results shown, "and N more" text if results exceed 10 - -- Click links media to selected post (updates sidecar linkedPostIds) - -- Linked posts list refreshes immediately -} - -rule MediaTranslationEdit { - when: MediaTranslationEditClicked(media_id, language) - -- Loads translation fields inline (title, alt, caption) for that language - -- Edit in place, save persists to translated sidecar {path}.{lang}.meta -} - -rule MediaTranslationRefresh { - when: MediaTranslationRefreshClicked(media_id, language) - -- Gate: airplane mode check - -- Re-translates from source language to target via title model - -- Overwrites existing translation fields - -- Rewrites translated sidecar file -} - -rule MediaTranslationDelete { - when: MediaTranslationDeleteClicked(media_id, language) - -- Deletes translation record from DB - -- Deletes translated sidecar file: {path}.{lang}.meta - -- No confirmation dialog -} - --- ─── Settings view ──────────────────────────────────────────── - --- VS Code-style settings page with header + search bar + scrollable sections. --- Search filters sections by keyword match. "No results" with clear button. - --- 8 sections (style is a separate tab, not a settings section): - --- 1. Project Settings: --- Project Name (text), Project Description (textarea 3 rows), --- Data Path (text + Browse + Reset), Public URL (url), --- Main Language (select), Blog Languages (checkboxes, main disabled), --- Default Author (text), Max Posts Per Page (number 1-500 default 50), --- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save - --- 2. Editor Settings: --- Default Editor Mode (select: WYSIWYG/Markdown/Preview), --- Diff View Style (select: Inline/Side-by-side), --- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox) - --- 3. Content Settings (Categories): --- Table: Category | Title | Render in Lists | Show Titles | Post Template | List Template | Remove --- Protected categories: article, aside, page, picture (cannot be deleted) --- Add Category: text input + Add button. Reset to Defaults button - --- 4. AI Assistant Settings: --- API keys: OpenCode, Mistral (masked display + Change, or password input + Save) --- Local providers: Ollama enable checkbox + capabilities table, --- LM Studio enable checkbox + capabilities table --- Offline mode: enable checkbox (disabled if no local providers), --- selectors for chat/title/image models (local only) --- Default Model (select with optgroup by provider + refresh), --- shows: max output tokens, context window, pricing --- Title Model (select), Image Analysis Model (vision-capable only) --- System Prompt (textarea 12 rows + Save + Reset to Default) - --- 5. Technology Settings: --- Python Runtime Mode (select: Web Worker/Main Thread) --- Semantic Similarity (checkbox) - --- 6. Publishing Settings (SSH): --- Info: SSH key auth only --- SSH Mode (select: scp/rsync), SSH Host, SSH Username, SSH Remote Path --- Save + Clear buttons - --- 7. Data Maintenance: --- Rebuild buttons (6): Posts from Files, Media from Files, Scripts from Files, --- Templates from Files, Links, Regenerate Missing Thumbnails --- Open Data Folder button - --- 8. MCP Server Settings: --- Status badge (port or "Not running") --- Per-agent rows: Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI, --- OpenCode, Mistral Vibe, OpenAI Codex — each with Add/Remove toggle - --- ─── Settings view actions ────────────────────────────────── - --- API key validation: --- On Save: test API call to provider endpoint before persisting --- On validation failure: toast error message, key not saved --- On success: key saved, masked display shown - --- Local provider enable/disable: --- Enabling Ollama/LM Studio: fetches model list, shows capabilities table --- Capabilities table columns: model name, chat, title, image (checkmarks) --- Models auto-fetched on enable; manual refresh via Refresh Models button - --- Model catalog refresh: --- Refresh button next to Default Model selector --- Fetches available models from all enabled providers --- Updates optgroup dropdown with provider groupings --- Per-model info: max output tokens, context window, pricing - --- Rebuild operations (Data Maintenance section): --- 6 buttons, each executes immediately (no confirmation dialog) --- Runs as background task with progress visible in Tasks panel --- On completion: wholesale replaces store data for that entity type --- Sidebar and editors re-render with fresh data - --- Category management (Content Settings): --- Protected categories: article, aside, page, picture (Remove button disabled) --- Add Category: text input + Add button, validates non-empty and unique --- Per-category settings: editable inline in table row --- "Reset to Defaults" restores default categories set - --- MCP per-agent config: --- Add/Remove toggle per agent row --- Status badge: shows port number when running, "Not running" otherwise - --- Bookmarklet (Project Settings): --- Copy button copies bookmarklet JavaScript to clipboard --- Bookmarklet uses project's publicUrl to construct POST endpoint - --- ─── Style view ─────────────────────────────────────────────── - --- Simple vertical layout: header, theme picker, controls row, preview. - --- Theme Picker: grid of theme buttons (one per Pico CSS theme). --- Each button: swatch with 3 colour tones (accent, light bg, dark bg), theme name. --- Selected theme: highlighted with aria-pressed. - --- Controls row: Preview Mode dropdown (Auto/Light/Dark), Apply Theme button. - --- Preview: iframe of style preview page at 127.0.0.1:4123/__style-preview --- with theme and mode query params. Updates live on selection change. - --- Apply Theme persists to project metadata. - --- ─── Style view actions ───────────────────────────────────── - -rule StyleThemeSelect { - when: StyleThemeSelected(theme_name) - -- Updates iframe preview immediately (query param change) - -- Does NOT persist until Apply is clicked -} - -rule StyleApplyTheme { - when: StyleApplyRequested(theme_name) - -- Persists picoTheme to project metadata (meta/project.json) - -- See flows.allium UpdateProjectMetadataSideEffects -} - --- Preview mode dropdown (Auto/Light/Dark): --- Controls iframe query param, live update on change --- Does not persist — local UI state only - --- ─── Tags view ──────────────────────────────────────────────── - --- Three sections controlled by sidebar TagsNav: - --- 1. Cloud: tag cloud visualization. Tags sized by post count, --- coloured by tag colour. Clickable to select for editing. - --- 2. Manage (Create/Edit): tag name input, colour picker, template select. --- Edit existing tag or create new. Delete button with confirmation. - --- 3. Merge: source tag select, target tag select, Merge button. --- Merges all posts from source tag into target, removes source. - --- ─── Tags view actions ────────────────────────────────────── - --- Cloud section: --- Tags sized by post count (same 11-22px scaling as dashboard) --- Tags with colours get coloured background --- Click selects tag: switches to Manage section, loads tag data - --- Manage section: --- Create new: empty form, Save creates tag + writes tags.json --- Edit existing: loads tag data into form, Save updates --- Tag name input, colour picker, template select (optional) --- Colour picker: 17 preset colour swatches + custom hex input field --- Delete: opens ConfirmDialog showing post count --- "This tag is used in N posts. Delete anyway?" --- On confirm: background task removes tag from all posts, --- rewrites published .md files, deletes tag, writes tags.json - --- Merge section: --- Source tag multi-select, target tag single-select --- Merge button: opens ConfirmDialog --- "Merge N tags into {target}? This cannot be undone." --- On confirm: background task updates all affected posts, --- rewrites published .md files, deletes source tags, writes tags.json - --- Sync (maintenance action): --- Rewrites tags.json from current DB state - --- ─── Chat panel ─────────────────────────────────────────────── - --- Three-region vertical layout: header, message area, input area. - --- API Key Required screen (shown when no API key): --- Key icon, title, description, "Open Settings" button - --- Header: --- Left: conversation title (or "New Chat"), CSS ellipsis on overflow --- Right: model selector button + dropdown. Groups models by provider. - --- Message area: --- Welcome screen (no messages, not streaming): robot icon, tips --- Message list: each has avatar (user=person, assistant=robot), role label, text --- User messages: plain text --- Assistant messages: rendered as GFM Markdown --- External images blocked (CSP), shown as links --- Tool markers: checkmark/dot, tool name, truncated args (30 char strings) --- Streaming: accumulating markdown + tool markers, thinking dots animation --- Auto-scrolls to bottom on new messages - --- Input area: --- Abort/Stop button (only during streaming, square stop icon) --- Auto-growing textarea (max 200px). Enter sends, Shift+Enter newline --- Send button (up-arrow icon), disabled when empty or streaming - --- Token usage tracked per conversation (displayed in status bar, not chat panel). --- Per-conversation model override via header selector. - -config { - chat_tool_args_max_length: Integer = 30 - chat_input_max_height: Integer = 200 -} - --- ─── Chat panel actions ───────────────────────────────────── - --- Model selector: --- Dropdown in header, groups models by provider (optgroup) --- Selection is per-conversation override, persisted with conversation --- Changing model mid-conversation applies to subsequent messages only - --- Streaming behaviour: --- Messages accumulate in real-time (markdown rendered incrementally) --- Tool call markers: checkmark icon (done) or dot (in-progress), --- tool name, args truncated to 30 chars for string values --- Thinking animation (three dots) during LLM processing - --- Stop/Abort: --- Square stop icon button, visible only during active streaming --- Stops current generation, partial message preserved as-is - --- Assistant action dispatch: --- Assistant tool calls can trigger navigation actions: --- open_post(id), open_media(id), open_settings(), etc. --- Actions dispatched through store, same as user clicks --- Navigation actions open tabs with pin intent - --- ─── Git diff view ──────────────────────────────────────────── - --- Renders diff for a file (working tree vs HEAD) or a commit. --- File diff: id = "git-diff:{filePath}" --- Commit diff: id = "git-diff:commit:{commitHash}" --- Supports inline and side-by-side diff display modes (from editor settings). --- No actions beyond viewing — changes are managed via git sidebar. - --- ─── Menu editor view ──────────────────────────────────────── - --- Visual editor for the OPML navigation menu (meta/menu.opml). --- See menu.allium for data model. - --- Layout: toolbar at top, tree view of menu items below. --- Each item row: drag handle, icon, label, type badge, action buttons. --- Nested items indented to show hierarchy. - --- ─── Menu editor actions ──────────────────────────────────── - --- Add item (toolbar buttons): --- Page entry: opens lazy-loaded page picker (posts with "page" category) --- Category archive entry: opens lazy-loaded category picker --- Submenu: creates empty container node for nesting children --- Home entry: always available, maximum one allowed - --- Save: serializes tree to OPML 2.0, writes meta/menu.opml - --- Move operations (per-item buttons): --- Up/Down: reorder within same nesting level --- Indent: nest under previous sibling (becomes child) --- Unindent: move to parent's level (becomes next sibling of parent) --- Home item: protected — cannot be moved or deleted - --- Drag-and-drop reorder: --- Drag handle on each item --- Auto-expand collapsed submenus on hover (450ms delay) --- Drop indicators show target position and nesting level - --- Delete: removes item and all children, no confirmation dialog --- Exception: home item cannot be deleted (button hidden) - --- ─── Metadata diff view ────────────────────────────────────── - --- Shows DB vs filesystem differences for all entity types. --- See metadata_diff.allium for diff field definitions. - --- Layout: scan button at top, results grouped by entity type below. --- Each diff entry: entity name, field name, DB value, file value, --- two sync buttons (DB->File, File->DB). - --- ─── Metadata diff actions ────────────────────────────────── - --- Scan: runs 4 parallel scans (posts, media, scripts, templates) --- Compares DB records against filesystem files field by field --- Results grouped by entity type, sorted by entity name --- Shows count of differences per entity type in section header - --- Per-field sync: --- DB -> File button: overwrites file field with DB value --- File -> DB button: overwrites DB field with file value --- No confirmation dialog for individual field syncs --- Button disappears after successful sync (field now matches) - --- Orphan file import: --- Files on disk with no matching DB record shown separately --- "Import" action creates DB record from file metadata --- Appears at bottom of each entity type section - --- ─── Documentation views ───────────────────────────────────── - --- documentation: renders DOCUMENTATION.md as styled HTML --- api_documentation: renders API.md as styled HTML - --- ─── Validation views ──────────────────────────────────────── - --- site_validation: checks generated site for broken links, missing assets --- translation_validation: checks translation completeness across languages - --- ─── Validation view actions ──────────────────────────────── - --- Site validation: --- Scans generated HTML for broken internal links, missing assets --- Results: list of issues with file path + line + description --- No auto-fix actions — informational only - --- Translation validation: --- Checks translation completeness across configured blog languages --- Results: matrix of posts x languages, missing translations highlighted --- No auto-fix actions — informational only - --- ─── Find duplicates view ──────────────────────────────────── - --- Uses embedding vectors to find semantically similar posts. --- Shows candidate pairs with similarity score. --- See embedding.allium for vector details. - --- ─── Find duplicates actions ──────────────────────────────── - --- Uses HNSW index of post embedding vectors for similarity search --- Shows candidate pairs: post A title, post B title, score (0.0-1.0) --- Click on either post title opens it in a tab (preview intent) --- No auto-merge or auto-delete actions - --- ─── Script editor view ────────────────────────────────────── - --- Code editor for Lua scripts (macros, utilities, transforms). --- See script.allium for entity model. - --- Layout: header bar, code editor, no preview. --- Header: script title (editable), kind badge, enabled toggle. --- Editor: code editor with Lua syntax highlighting. --- Toolbar: Run button, Save button, Delete button, entrypoint selector. - --- ─── Script editor actions ────────────────────────────────── - -rule ScriptSave { - when: ScriptSaveRequested(script_id) - -- Lua syntax check first (parse validation) - -- If syntax error: blocks save, shows error inline in editor gutter - -- If valid: bumps version, saves to DB + rewrites .lua file - -- See flows.allium for UpdateScriptSideEffects -} - -rule ScriptRun { - when: ScriptRunRequested(script_id) - -- Executes script in Lua runtime - -- Calls configured entrypoint function (default: render) - -- stdout/stderr directed to Output panel tab - -- Output panel auto-opens if not already visible - -- Errors shown in Output panel with line numbers -} - -rule ScriptDelete { - when: ScriptDeleteRequested(script_id) - -- No confirmation dialog - -- Deletes DB record + .lua file on disk - -- Closes script tab, sidebar removes item -} - --- ─── Template editor view ───────────────────────────────────── - --- Code editor for Liquid templates (post, list, not_found, partial). --- See template.allium for entity model and Liquid subset. - --- Layout: header bar, code editor, preview pane. --- Header: template title (editable), kind badge, enabled toggle, version display. --- Editor: code editor with Liquid syntax highlighting. --- Preview: rendered template output in iframe (uses sample post data). --- Toolbar: Save button, Delete button. - --- ─── Template editor actions ──────────────────────────────── - -rule TemplateSave { - when: TemplateSaveRequested(template_id) - -- Liquid validation first (parse check for syntax errors) - -- If invalid: blocks save, shows error inline in editor - -- If valid: bumps version, saves to DB + rewrites .liquid file - -- See flows.allium for UpdateTemplateSideEffects -} - -rule TemplateDelete { - when: TemplateDeleteRequested(template_id) - -- Checks for references: posts using this template, tags with postTemplateSlug - -- If references exist: native confirm dialog (rfd) - -- "This template is used by N posts and M tags. Force delete?" - -- Force delete: nulls templateSlug on referencing posts, - -- nulls postTemplateSlug on referencing tags - -- If no references: deletes without confirmation - -- Deletes DB record + .liquid file on disk - -- Closes template tab, sidebar removes item -} - --- ─── Import analysis view ─────────────────────────────────── - --- Editor for WXR (WordPress eXtended RSS) import definitions. --- Keyed by import definition ID. Opened as always-pinned tab. --- Workflow: Select File -> Analyze -> Resolve Conflicts -> Execute - --- Layout: header (definition name), file selector, analysis results, --- conflict resolution panel, taxonomy mapping, execute button. - --- ─── Import analysis actions ──────────────────────────────── - -rule ImportSelectAndAnalyze { - when: ImportAnalyzeRequested(definition_id, file_path) - -- Parses WXR XML file - -- Extracts: posts, pages, media, tags, categories, authors - -- Shows summary counts per entity type - -- Identifies conflicts: duplicate slugs, existing categories/tags -} - --- Conflict resolution: --- Per-item dropdown: Import (create new), Skip (ignore), Merge (combine) --- Default: Import for new items, Skip for existing matches - --- Taxonomy mapping: --- Source categories/tags mapped to existing project categories/tags --- Dropdown per source taxonomy term -> target term or "Create New" --- AI taxonomy analysis (optional): --- Gate: airplane mode check --- Suggests mappings based on name similarity via title model - -rule ImportExecute { - when: ImportExecuteRequested(definition_id) - -- No confirmation dialog — executes immediately - -- Processes items per conflict resolution settings - -- Creates posts, media, tags, categories as needed - -- Summary with counts shown in import view on completion - -- Created entities appear in sidebar immediately (store updated) -} diff --git a/specs/engine_side_effects.allium b/specs/engine_side_effects.allium new file mode 100644 index 0000000..1e5da20 --- /dev/null +++ b/specs/engine_side_effects.allium @@ -0,0 +1,314 @@ +-- allium: 1 +-- bDS Engine-Level Save Side-Effects +-- Scope: cross-cutting (all waves) +-- Distilled from: PostEngine.ts, MediaEngine.ts, TemplateEngine.ts, +-- ScriptEngine.ts, MetaEngine.ts, TagEngine.ts + +-- 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. + +use "./post.allium" as post +use "./media.allium" as media + +-- ─── External surfaces ────────────────────────────────────── + +-- 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) +} + +-- ─── 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) +} + +-- ─── 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) +} + +-- ─── 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) +} + +-- ─── 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 + +-- ─── 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() +} + +-- ─── 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) +} + +-- ─── 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) +} + +-- ─── 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 + +-- ─── 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 diff --git a/specs/flows.allium b/specs/flows.allium deleted file mode 100644 index d738d99..0000000 --- a/specs/flows.allium +++ /dev/null @@ -1,640 +0,0 @@ --- 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 diff --git a/specs/modals.allium b/specs/modals.allium new file mode 100644 index 0000000..788d63b --- /dev/null +++ b/specs/modals.allium @@ -0,0 +1,168 @@ +-- allium: 1 +-- bDS Shared Modals +-- Scope: UI overlays used across multiple editor views +-- Distilled from: PostEditor.tsx, MediaEditor.tsx + +-- Shared modal components used by multiple editors. +-- Editor-specific modals live in their respective editor spec files. + +use "./i18n.allium" as i18n + +-- ─── AI Suggestions Modal ──────────────────────────────────── + +-- Shared modal for presenting AI suggestions with per-field accept/reject. +-- Used by PostAIAnalysis (editor_post.allium) and +-- MediaAIImageAnalysis (editor_media.allium). + +value AISuggestionsModal { + fields: List + -- Layout: title bar ("AI Suggestions"), scrollable field list, button row + -- Each field rendered as a row: + -- Left: checkbox (accept/reject), label + -- Center: current value (read-only, muted), arrow, suggested value (highlighted) + -- Special: slug field checkbox disabled if post was ever published + -- Buttons: Cancel (secondary), Apply Selected (primary) + -- Cancel discards all; Apply writes only accepted fields to entity +} + +value AISuggestionField { + label: String + current_value: String + suggested_value: String + accepted: Boolean -- checkbox, default true + locked: Boolean -- if true, checkbox disabled (e.g. published slug) +} + +-- ─── Insert Post Link Modal ────────────────────────────────── + +value InsertPostLinkModal { + -- Two-tab modal opened by Ctrl/Cmd+K in post editor (markdown mode). + -- Tab 1 - Internal: + -- Search input (debounced 300ms, queries post titles) + -- Results list: post title + status badge (draft/published/archived) + -- If semantic similarity enabled: results ranked by vector similarity + -- Click result: inserts [title](/YYYY/MM/DD/slug) at cursor, closes modal + -- "Create Post" row at bottom of results: + -- Creates new post with search query as title, inserts link to it + -- Tab 2 - External: + -- URL input field (required) + -- Display text input field (optional) + -- Insert button: inserts [text](url) or bare url if no text, closes modal + active_tab: String -- internal | external + search_query: String + results: List +} + +value InsertLinkResult { + post_id: String + title: String + status: String -- draft | published | archived + canonical_url: String -- /YYYY/MM/DD/slug +} + +-- ─── Insert Media Modal ────────────────────────────────────── + +value InsertMediaModal { + -- Grid modal for inserting media references into post content. + -- Search input filtering by media title and original filename. + -- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below. + -- Click item: + -- Images: inserts ![alt](bds-media://id) at cursor + -- Non-images: inserts [originalName](bds-media://id) at cursor + -- Closes modal after insertion. + search_query: String + results: List +} + +value InsertMediaResult { + media_id: String + title: String + original_name: String + is_image: Boolean + thumbnail_url: String? -- bds-thumb:// for images, null for others +} + +-- ─── Language Picker Modal ─────────────────────────────────── + +value LanguagePickerModal { + -- Shown for Translate Post and Translate Media Metadata actions. + -- Lists all configured blogLanguages except source language. + -- Each row: flag emoji, language name, status badge if translation exists. + -- Existing translations show "(draft)" or "(published)" badge. + -- Click selects target language and initiates translation flow. + -- Cancel closes without action. + source_language: String + available_targets: List +} + +value LanguageTarget { + code: String + name: String + flag_emoji: String + has_existing_translation: Boolean + existing_status: String? -- draft | published, if translation exists +} + +-- ─── Confirm Delete Modal ──────────────────────────────────── + +value ConfirmDeleteModal { + -- Custom styled modal for destructive operations with reference info. + -- Used by: MediaDelete (shows linked posts), TagDelete (shows post count). + -- Layout: warning icon, title, entity name, reference section, buttons. + -- Reference section: "This item is referenced by:" + bulleted list. + -- Buttons: Cancel (secondary), Delete (destructive red). + entity_name: String + entity_type: String -- media | tag + reference_count: Integer + reference_list: List -- titles of referencing entities +} + +-- ─── Confirm Dialog ────────────────────────────────────────── + +value ConfirmDialog { + -- Custom styled modal for non-delete confirmations. + -- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone."). + -- Layout: title, descriptive message, buttons. + -- Buttons: Cancel (secondary), Confirm (primary). + title: String + message: String +} + +-- Native confirm dialogs (via rfd crate) are NOT modelled as values. +-- They are simple yes/no system dialogs with a message string. +-- Used by: PostDelete, PostDiscard, TemplateDelete (with references). + +-- ─── Gallery Overlay ───────────────────────────────────────── + +value GalleryOverlay { + -- Full-screen overlay showing all media linked to a post. + -- Opened from Gallery button in post editor toolbar (markdown mode). + -- Image grid: bds-thumb:// thumbnails (medium 400px), 3-4 columns. + -- Click image: opens LightboxView for that image. + -- Close: X button or ESC key. + post_id: String + images: List +} + +value GalleryImage { + media_id: String + thumbnail_url: String -- bds-thumb://media_id + alt_text: String? +} + +value LightboxView { + -- Full-screen image viewer, sub-view of GalleryOverlay. + -- Shows single image at full resolution via bds-media:// protocol. + -- Navigation: left/right arrow buttons, keyboard left/right arrow keys. + -- Close: X button, ESC key, or click outside image area. + -- Header: image title or filename, index counter "3 of 12". + current_index: Integer + total_count: Integer + media_id: String + image_url: String -- bds-media://media_id + alt_text: String? +} + +-- All modals rendered as centered overlay with backdrop dimming. +-- ESC key or backdrop click closes modal (cancel semantics). +-- Overlays (PostPicker, ColourPicker) are positioned inline near trigger. diff --git a/specs/ui_data_flow.allium b/specs/ui_data_flow.allium new file mode 100644 index 0000000..326a4c5 --- /dev/null +++ b/specs/ui_data_flow.allium @@ -0,0 +1,203 @@ +-- allium: 1 +-- bDS UI Data Flow Model +-- Scope: cross-cutting (all waves) +-- Distilled from: appStore.ts, PostEditor.tsx, MediaEditor.tsx, Editor.tsx, +-- Sidebar.tsx, 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 backend->UI event propagation. + +use "./layout.allium" as layout +use "./tabs.allium" as tabs +use "./sidebar_views.allium" as sidebar + +-- ─── 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) +} + +-- ─── 1. Sidebar -> Editor flows ────────────────────────────── + +-- 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. + +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 editor_post.allium PostDeleteAction / editor_media.allium MediaDeleteAction. +} + +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. +} + +-- ─── 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 +} + +-- ─── 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) +} + +-- ─── 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 + +-- ─── 5. 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