-- 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 -- ─── Post editor ────────────────────────────────────────────── value PostEditorView { post_id: String header: PostEditorHeader metadata: PostEditorMetadata metadata_expanded: Boolean -- starts expanded when title is empty excerpt_expanded: Boolean editor_mode: String -- markdown | preview footer: PostEditorFooter } value PostEditorHeader { title: String -- post title or "Untitled" is_dirty: Boolean status: String -- draft | published | archived is_auto_saving: Boolean } value PostEditorMetadata { title: String -- editable text input tags: List -- autocomplete chip input author: String? -- text input language: String? -- select from supported languages do_not_translate: Boolean -- checkbox slug: String -- read-only text input categories: List -- chip input template_slug: String? -- select (shown only when templates exist) post_links: PostLinksPanel linked_media: List } value PostLinksPanel { backlinks: List -- posts linking to this post outlinks: List -- posts this post links to } value PostLinkReference { post_id: String title: String } value LinkedMediaItem { media_id: String has_thumbnail: Boolean name: String sort_order: Integer } value PostEditorFooter { created_at: String -- locale-formatted date updated_at: String -- locale-formatted date published_at: String? -- locale-formatted date, only when post was published } value TranslationFlag { language: String flag_emoji: String status: String -- draft | published is_active: Boolean -- true when this language is currently being edited } surface TranslationFlagSurface { context flag: TranslationFlag exposes: flag.language flag.flag_emoji flag.status flag.is_active } surface PostEditorSurface { context editor: PostEditorView exposes: editor.header.title editor.header.is_dirty editor.header.status editor.header.is_auto_saving editor.metadata_expanded editor.excerpt_expanded editor.editor_mode editor.footer.created_at editor.footer.updated_at editor.footer.published_at when editor.footer.published_at != null provides: PostAIAnalysisRequested(editor.post_id) PostTranslateRequested(editor.post_id, target_language) PostSaved(editor.post_id) PostPublishRequested(editor.post_id) when editor.header.status = draft PostDiscardRequested(editor.post_id) when editor.header.status = draft PostDeleteRequested(editor.post_id) PostInsertLinkRequested(editor.post_id) when editor.editor_mode = markdown PostInsertMediaRequested(editor.post_id) when editor.editor_mode = markdown PostGalleryRequested(editor.post_id) ImageDroppedOnEditor(editor.post_id, file_path) PostLanguageDetectRequested(editor.post_id) PostDiscardConfirmed(editor.post_id) PostDeleteConfirmed(editor.post_id) @guarantee HeaderLayout -- Header bar with two areas. -- Left: title text with dirty indicator dot (●) when is_dirty is true. -- Right: status badge, auto-save indicator (when saving), -- Quick Actions dropdown, Publish button (only when draft, success style), -- Discard button (only when draft), Delete button. @guarantee QuickActionsDropdown -- Dropdown menu in header with two entries: -- AI Analysis (robot icon) — suggests title, excerpt, slug. -- Translate Post (globe icon) — opens translation modal. @guarantee MetadataSection -- Collapsible section. Starts expanded when title is empty. -- Two-column layout. -- Left column: Title, Tags, Author, Language + detect button, -- Do Not Translate checkbox, Slug (read-only), Categories, -- Template (select, only when templates exist), PostLinks. -- Right column: LinkedMediaPanel. @guarantee TagAutocomplete -- Tag input with autocomplete. -- While typing: substring match on existing tag names (case-insensitive), -- top 8 shown, plus a "create tag" row when the query is new. -- While the query is empty (on focus): semantic suggestions appear under a -- "Suggested tags" label — tags drawn from up to 10 similar posts, -- weighted by similarity, top 5, excluding tags already on the post. -- Requires semanticSimilarityEnabled and a built embedding index; -- it is an index read (no model inference) so it works offline and -- yields nothing when similarity is disabled or unindexed. @guarantee TranslationFlagsBar -- Row of flag emoji buttons inline with metadata toggle. -- One flag per language: canonical language + each translation. -- Each flag shows status (draft/published) via CSS class. -- Active flag highlighted. Click switches editor to that language's draft. @guarantee ExcerptSection -- Collapsible section with textarea (4 rows). @guarantee EditorBodyToolbar -- Toolbar: "Content" label, mode toggle (Markdown/Preview), -- action buttons (markdown mode only): Gallery (with media count), -- Insert Post Link, Insert Media. @guarantee EditorModes -- Markdown: code editor with markdown-with-macros language, -- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. -- Preview: iframe served by the localhost preview server at the post's -- normal generated-site URL, using the shared site renderer. Draft -- content comes from DB; published content and assets come from files. -- Markdown and Preview are the only editor modes. @guarantee DragDropImages -- Drop image file onto editor area triggers import chain. @guarantee FooterLayout -- Three date stamps: Created, Updated, Published (only when published_at exists). -- Locale-formatted dates. @guarantee LinkedMediaPanel -- Right column of metadata showing media items linked to this post. -- Actions: Import & Link (native file dialog), Link Existing (media picker), -- Unlink (no confirmation), Reorder (drag handle), Click (opens media tab). } config { post_auto_save_delay: Integer = 3000 post_content_sample_length: Integer = 2000 } invariant PostAutoSave { -- Auto-saves after config.post_auto_save_delay ms of idle in the editor. -- Also auto-saves on unmount/tab switch. -- Ctrl/Cmd+S triggers immediate save. -- Saves: title, content, excerpt, tags, categories, templateSlug, language. } invariant PostDirtyTracking { -- Compares canonical draft + translation drafts against saved state. -- Dirty indicator shown in header (●) and tab bar. } invariant PostEditorModePersistence { -- Editor mode (markdown/preview) persists per session. -- Default mode comes from editor settings (markdown). } -- ─── Post editor actions ──────────────────────────────────── rule PostAIAnalysis { when: PostAIAnalysisRequested(post_id) ensures: AISuggestionRequested(entity_type: "post", entity_id: post_id) -- Shared flow: action_patterns.allium PostAISuggestionFlow — -- airplane gating, title model (not the default chat model) -- over title + excerpt + content sample, AISuggestionsModal -- with title/excerpt/slug (slug locked once published), -- accepted fields re-arm the auto-save timer. } rule PostTranslateAction { when: PostTranslateRequested(post_id, target_language) let target = post/Post{id: post_id} requires: active_endpoint_configured -- airplane-mode gating: ai.allium AirplaneModeGating requires: target_language in target.project.blog_languages -- chosen in the language picker modal; existing translations -- are listed with their status badge (draft/published) let translated_meta = ai_translate_post_metadata(target, target_language) -- AI call 1: title + excerpt via the title model let translated_content = ai_translate_post_content(target, target_language) -- AI call 2 (sequential): full markdown body via the title model ensures: PostTranslationUpserted( translation_record(target, target_language, translated_meta, translated_content), target) -- creates/updates the translation record; a published source -- post transitions back to draft with the file content copied -- to the DB so it can be edited (engine_side_effects.allium -- UpsertPostTranslationSideEffects) } -- Auto-translation on manual save is specified once in -- action_patterns.allium AutoTranslationChain (triggered by PostSaved), -- including the linked-media cascade. It is not duplicated here. rule PostPublishAction { when: PostPublishRequested(post_id) let target = post/Post{id: post_id} ensures: if editor_is_dirty(post_id): PostSaved(post_id) -- implicit save first (awaited) ensures: PublishPostRequested(target) -- engine publish: file write, translation publishing, FTS, -- links (engine_side_effects.allium PublishPostSideEffects) -- UI updates reactively: status badge -> published, sidebar -- section move (ui_data_flow.allium PostStatusChanged) } rule PostDiscardChanges { when: PostDiscardRequested(post_id) let target = post/Post{id: post_id} requires: target.published_at != null requires: target.status = draft -- only published posts with pending draft changes ensures: DiscardConfirmShown(post_id) -- system confirm dialog: "Discard changes to this post?" -- (action_patterns.allium confirmation_assignments: -- post_discard) } rule PostDiscardExecute { when: PostDiscardConfirmed(post_id) let target = post/Post{id: post_id} ensures: DiscardPostChangesRequested(target) -- engine restores the published state from the .md file: -- content = null, status = published ensures: EditorReloaded(post_id) -- editor reloads with the restored content } rule PostDeleteAction { when: PostDeleteRequested(post_id) ensures: DeleteConfirmShown(post_id) -- system confirm dialog: "Delete this post?" -- (action_patterns.allium confirmation_assignments: post_delete) } rule PostDeleteExecute { when: PostDeleteConfirmed(post_id) let target = post/Post{id: post_id} ensures: DeletePostRequested(target) -- published posts also delete the .md file and all translation -- files; never-published drafts only the DB record -- (engine_side_effects.allium DeletePostSideEffects) ensures: closeTab(post_id) -- sidebar removes the item reactively (ui_data_flow.allium -- PostEditorDelete) } rule PostInsertLink { when: PostInsertLinkRequested(post_id) -- Keyboard shortcut: Ctrl/Cmd+K (markdown mode only, per the -- provides guard) ensures: InsertPostLinkModalOpened(post_id) -- two tabs: Internal (debounced title search, similarity -- ranking, Create Post row) and External (URL + display text); -- picked entries insert [title](/YYYY/MM/DD/slug) or -- [text](url) at the cursor — modals.allium InsertPostLinkModal } rule PostInsertMedia { when: PostInsertMediaRequested(post_id) ensures: InsertMediaModalOpened(post_id) -- media search grid with bds-thumb:// thumbnails; picked items -- insert ![alt](/media/YYYY/MM/file) for images or -- [originalName](/media/YYYY/MM/file) otherwise — -- modals.allium InsertMediaModal } rule PostGalleryAction { when: PostGalleryRequested(post_id) ensures: GalleryOverlayOpened(post_id) -- grid of all media linked to this post; clicking an image -- opens the lightbox (full-size preview, arrow navigation, -- ESC to close) — modals.allium GalleryOverlay / LightboxView } rule PostDragDropImage { when: ImageDroppedOnEditor(post_id, file_path) ensures: ImageFileDroppedOnPostEditor(post_id, file_path) -- full chain: action_patterns.allium PostDragDropImageChain -- (synchronous import + thumbnails + link + cursor insert, -- background AI auto-apply and translation cascade) } rule PostLanguageDetect { when: PostLanguageDetectRequested(post_id) let target = post/Post{id: post_id} requires: active_endpoint_configured let detected = ai_detect_language(post_suggestion_input(target)) -- title model over the content sample; no modal ensures: target.language = detected ensures: PostAutoSaveTimerReset(post_id) }