From 733aa9a6ffa9b23be174f4dc0b56146fca8b3971 Mon Sep 17 00:00:00 2001 From: "Bauer, Georg" Date: Wed, 22 Jul 2026 14:58:22 +0200 Subject: [PATCH] Formalize tier-1 comment-based specs into allium constructs. Co-Authored-By: Claude Fable 5 --- specs/action_patterns.allium | 273 ++++++++++++------ specs/editor_media.allium | 167 ++++++++--- specs/editor_post.allium | 175 ++++++------ specs/editor_script.allium | 66 +++-- specs/editor_template.allium | 80 ++++-- specs/i18n.allium | 91 ++++-- specs/schema.allium | 47 +++- specs/template.allium | 130 +++++---- specs/tui.allium | 530 ++++++++++++++++++++++++++--------- 9 files changed, 1088 insertions(+), 471 deletions(-) diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium index 7a69c35..3227d8f 100644 --- a/specs/action_patterns.allium +++ b/specs/action_patterns.allium @@ -15,114 +15,215 @@ surface UserAction { provides: AISuggestionRequested(entity_type, entity_id) provides: PostSaved(post_id) provides: PostAutoTranslateCompleted(post_id, language) + provides: ImageFileDroppedOnPostEditor(post_id, file) + provides: AISuggestionsModalConfirmed(entity_type, entity_id, fields) + provides: AISuggestionsModalCancelled(entity_type, entity_id) } --- ─── AI operation gating ─────────────────────────────── +surface AiEngine { + facing _: AiRuntime -invariant AIOperationGating { - -- All AI operations route through the active endpoint for the current mode. - -- See ai.allium AirplaneModeGating for endpoint selection logic. - -- If airplane_mode: use airplane endpoint (local model, e.g. Ollama/LM Studio) - -- If online: use online endpoint (cloud provider) - -- If active endpoint not configured: show toast, abort operation - -- 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 + provides: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions) + -- The active endpoint's response, parsed into per-field suggestions } --- ─── AI suggestions pattern ──────────────────────────── +-- ─── AI operation gating ──────────────────────────────────── +-- +-- All AI operations route through the active endpoint for the current +-- mode; endpoint selection and the active_endpoint_configured predicate +-- are specified in ai.allium (AirplaneModeGating, TwoEndpointModel). +-- Rules below gate on active_endpoint_configured and refuse with a +-- toast when it is false. Two model categories are used: +-- 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 the chat panel only. --- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis. --- See modals.allium AISuggestionsModal value type. - -rule AISuggestionFlow { +rule AiOperationRefusedWithoutEndpoint { 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 + requires: not active_endpoint_configured + ensures: ToastShown(message_key: "ai.unavailable_configure_endpoint") + -- "AI unavailable — configure {online|airplane} endpoint in Settings" + ensures: AiOperationAborted(entity_type, entity_id) } --- ─── Auto-translation chain ──────────────────────────── +-- ─── AI suggestions pattern ───────────────────────────────── +-- +-- Shared flow used by PostAIAnalysis (editor_post.allium) and +-- MediaAIImageAnalysis (editor_media.allium). Modal layout: +-- see modals.allium AISuggestionsModal / AISuggestionField. + +config { + post_suggestion_sample_length: Integer = 2000 +} + +rule PostAISuggestionFlow { + when: AISuggestionRequested(entity_type, entity_id) + requires: entity_type = "post" + requires: active_endpoint_configured + let target = post/Post{id: entity_id} + ensures: AiFieldSuggestionsRequested(entity_id, + model: "title", + input: post_suggestion_input(target)) + -- black box: title + excerpt + first + -- config.post_suggestion_sample_length chars of content +} + +rule MediaAISuggestionFlow { + when: AISuggestionRequested(entity_type, entity_id) + requires: entity_type = "media" + requires: active_endpoint_configured + let target = media/Media{id: entity_id} + ensures: AiFieldSuggestionsRequested(entity_id, + model: "image", + input: ai_thumbnail(target)) + -- black box: the 448x448 AI thumbnail JPEG +} + +rule PresentAiSuggestions { + when: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions) + -- Response parsed into per-field suggestions; each field row shows + -- label, current value, suggested value, and an accept checkbox + -- defaulting to true. The post slug field is locked (no checkbox) + -- if the post was ever published. + ensures: AISuggestionsModalOpened(entity_type, entity_id, suggestions) +} + +rule ApplyAcceptedSuggestions { + when: AISuggestionsModalConfirmed(entity_type, entity_id, fields) + let accepted = filter(fields, f => f.accepted and not f.locked) + for field in accepted: + ensures: SuggestionFieldApplied(entity_type, entity_id, field) + ensures: + if entity_type = "post": + PostAutoSaveTimerReset(entity_id) + -- Posts: applied fields go through the 3s auto-save timer + else: + MediaSaveRequested(entity_id) + -- Media: applied fields trigger an explicit save +} + +rule CancelAiSuggestions { + when: AISuggestionsModalCancelled(entity_type, entity_id) + ensures: AiSuggestionsDiscarded(entity_type, entity_id) + -- All suggestions discarded, no entity changes +} + +-- ─── Auto-translation chain ───────────────────────────────── rule AutoTranslationChain { when: PostSaved(post_id) - -- Gate: AIOperationGating + post.doNotTranslate must be false - -- Triggered only by explicit manual save or publish, never by auto-save, - -- unmount/tab-switch persistence, post creation, import, or scripting. - -- For each configured blogLanguage missing a translation for this post: - -- 1. Enqueue one background task for that language - -- 2. Translate metadata (title, excerpt) via title model - -- 3. Translate content (full markdown) via title model - -- 4. Create/update translation record in DB - -- A later manual save with no missing languages enqueues no task. - -- Work is sequential within a language and parallel across languages. - -- Progress visible in Tasks panel + -- Triggered only by explicit manual save or publish, never by + -- auto-save, unmount/tab-switch persistence, post creation, import, + -- or scripting. + let saved_post = post/Post{id: post_id} + requires: active_endpoint_configured + requires: not saved_post.do_not_translate + let missing = saved_post.project.blog_languages - saved_post.available_languages + for language in missing: + ensures: PostTranslationTaskEnqueued(post_id, language) + -- One background task per missing language. Each task: + -- 1. translate metadata (title, excerpt) via title model + -- 2. translate content (full markdown) via title model + -- 3. create/update the translation record in the DB + -- Sequential within a language, parallel across languages; + -- progress visible in the Tasks panel. A save with no missing + -- languages enqueues nothing (empty set). } 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 + let saved_post = post/Post{id: post_id} + for link in saved_post.linked_media: + let item = media/Media{id: link.media_id} + if item.language != null + and not exists media/MediaTranslation{media: item, language: language}: + ensures: MediaMetadataTranslationTaskEnqueued(link.media_id, language) + -- Translates title, alt, caption via the title model and + -- writes the translated sidecar {path}.{lang}.meta } --- ─── Drag-and-drop image chain ───────────────────────── +-- ─── Drag-and-drop image chain ────────────────────────────── +-- +-- Full chain when an image file is dropped on the post editor body. +-- See editor_post.allium PostDragDropImage for the trigger rule. --- Full chain when image file is dropped on post editor body. --- See editor_post.allium PostDragDropImage for trigger rule. +rule PostDragDropImageChain { + when: ImageFileDroppedOnPostEditor(post_id, file) + let target = post/Post{id: post_id} + -- Synchronous steps (user waits): + let imported = import_media(target.project, file) + -- black box: new media record + file copy + base sidecar + -- (media.allium ImportMedia) + ensures: ThumbnailGenerationStarted(imported) + -- async start: small/medium/large/ai variants + ensures: MediaLinkedToPost(imported, target) + -- updates sidecar linkedPostIds + ensures: MarkdownImageInserted(target, imported) + -- inserts ![](/media/YYYY/MM/file) at the cursor position + ensures: DragDropImportCompleted(target, imported) +} --- 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 ![](/media/YYYY/MM/file) at cursor +rule DragDropAiAutoApply { + when: DragDropImportCompleted(target, imported) + requires: active_endpoint_configured + -- Background, non-blocking: image model on the 448x448 AI thumbnail. + -- Results are auto-applied WITHOUT the AISuggestionsModal (unlike + -- the manual Quick Action). + ensures: MediaMetadataUpdated(imported, ai_image_analysis(imported)) + ensures: MediaSidecarRewritten(imported) +} --- 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 +rule DragDropTranslationCascade { + when: DragDropImportCompleted(target, imported) + requires: active_endpoint_configured + requires: not target.do_not_translate + for language in target.project.blog_languages: + ensures: MediaMetadataTranslationTaskEnqueued(imported.id, language) + -- creates translated sidecar files +} --- ─── Confirmation dialog patterns ────────────────────── +-- ─── Confirmation dialog patterns ─────────────────────────── +-- +-- Four distinct patterns used across the application. The assignment +-- table below is the authoritative mapping from destructive or +-- irreversible actions to their confirmation UI. --- Four distinct patterns used across the application: +enum ConfirmationPattern { + system_confirm + -- Simple yes/no system dialog, no custom UI + confirm_delete_modal + -- Custom modal with entity name, reference counts, linked + -- entity list; buttons Cancel + Delete (destructive red style) + confirm_dialog + -- Custom modal describing the action and its consequences; + -- buttons Cancel + Confirm + none + -- Immediate execution on click, no dialog +} --- Pattern 1: System confirm dialog --- Simple yes/no system dialog, no custom UI --- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist) +value ConfirmationAssignment { + action: String + pattern: ConfirmationPattern +} --- 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 +config { + confirmation_assignments: Set = { + ConfirmationAssignment(action: "post_delete", pattern: system_confirm), + ConfirmationAssignment(action: "post_discard", pattern: system_confirm), + ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm), + ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal), + -- shows linked posts + ConfirmationAssignment(action: "tag_delete", pattern: confirm_delete_modal), + -- shows post count + ConfirmationAssignment(action: "tag_merge", pattern: confirm_dialog), + -- "Merge N tags into {target}? Cannot be undone." + ConfirmationAssignment(action: "rebuild_operations", pattern: none), + ConfirmationAssignment(action: "script_delete", pattern: none), + ConfirmationAssignment(action: "menu_item_delete", pattern: none), + ConfirmationAssignment(action: "metadata_diff_field_sync", pattern: none), + ConfirmationAssignment(action: "import_execute", pattern: none), + ConfirmationAssignment(action: "media_translation_delete", pattern: none), + ConfirmationAssignment(action: "media_unlink", pattern: none) + } +} diff --git a/specs/editor_media.allium b/specs/editor_media.allium index 3fba3e0..b63a319 100644 --- a/specs/editor_media.allium +++ b/specs/editor_media.allium @@ -7,6 +7,7 @@ -- the main content area when a media tab is active. use "./media.allium" as media +use "./post.allium" as post use "./i18n.allium" as i18n -- ─── Media editor ───────────────────────────────────────────── @@ -56,6 +57,9 @@ surface PostPickerOverlaySurface { result.post_id result.title overlay.overflow_count when overlay.overflow_count != null + + provides: + PostPickerResultClicked(media_id, post_id) } value PostPickerResult { @@ -103,6 +107,8 @@ surface MediaEditorSurface { MediaTranslationRefreshClicked(editor.media_id, language) MediaTranslationDeleteClicked(editor.media_id, language) MediaUnlinkPostRequested(editor.media_id, post_id) + MediaDeleteConfirmed(editor.media_id) + TranslationEditModalSaved(editor.media_id, language, title, alt, caption) @guarantee HeaderLayout -- Header bar with media display name. @@ -162,84 +168,153 @@ surface MediaEditorSurface { -- ─── Media editor actions ───────────────────────────────────── +rule MediaSave { + when: MediaSaveRequested(media_id) + let item = media/Media{id: media_id} + ensures: UpdateMediaRequested(item, editor_field_changes(media_id)) + -- Persists all form fields; the engine update rewrites the + -- sidecar and FTS entry (media.allium UpdateMedia, + -- engine_side_effects.allium UpdateMediaSideEffects) +} + 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 + -- Only available for image/* MIME types (editor.is_image guard + -- on the provides entry; button hidden for non-images). + ensures: AISuggestionRequested(entity_type: "media", entity_id: media_id) + -- Shared flow: action_patterns.allium MediaAISuggestionFlow — + -- airplane gating, image model over the 448x448 AI thumbnail, + -- AISuggestionsModal with title/alt/caption, accepted fields + -- trigger an 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 + let item = media/Media{id: media_id} + requires: active_endpoint_configured + -- airplane-mode gating: ai.allium AirplaneModeGating + let detected = ai_detect_language(concat(item.title, item.alt, item.caption)) + -- title model over the concatenated metadata text + ensures: UpdateMediaRequested(item, language_change(detected)) + -- persisted immediately — no modal, no confirmation; the + -- engine update rewrites the sidecar } 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 + let item = media/Media{id: media_id} + requires: active_endpoint_configured + -- target_language comes from the language picker modal (same + -- pattern as post translate). + let source_language = item.language + ?? ai_detect_language(concat(item.title, item.alt, item.caption)) + -- step 1: detect the source language first when unset + -- (auto-persisted, as in MediaDetectLanguage) + let translated = ai_translate_media_metadata(item, source_language, target_language) + -- step 2: translate title, alt, caption via the title model + ensures: UpsertMediaTranslationRequested(item, target_language, + translated.title, translated.alt, translated.caption) + -- creates/updates the translation record and writes the + -- translated sidecar {path}.{lang}.meta (media.allium + -- UpsertMediaTranslation) } 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 + let item = media/Media{id: media_id} + let source = native_file_dialog_selection() + -- native file dialog, no MIME type filter; cancel aborts + ensures: ReplaceMediaFileRequested(item, source) + -- copies over the existing media file path; images regenerate + -- thumbnails synchronously (media.allium ReplaceMediaFile) + ensures: MediaPreviewRefreshed(media_id) + -- preview reloads with a 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 + let item = media/Media{id: media_id} + ensures: ConfirmDeleteModalOpened(media_id, item.linked_posts) + -- custom modal (not a native dialog): media display name, + -- linked post count and list; Cancel + Delete (destructive + -- red style) — action_patterns.allium + -- confirmation_assignments: media_delete +} + +rule MediaDeleteExecute { + when: MediaDeleteConfirmed(media_id) + let item = media/Media{id: media_id} + ensures: DeleteMediaRequested(item) + -- file, sidecar, thumbnails, translations, post-media links, + -- FTS entry: media.allium DeleteMedia + + -- engine_side_effects.allium DeleteMediaSideEffects + ensures: closeTab(media_id) + -- sidebar removes the item reactively } 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 config.media_post_picker_max_results results shown - -- "and N more" text if results exceed limit - -- Click links media to selected post (updates sidecar linkedPostIds) - -- Linked posts list refreshes immediately + ensures: PostPickerOverlayOpened(media_id) + -- inline overlay near the button (not a modal): search input + -- filters unlinked posts by title, at most + -- config.media_post_picker_max_results results, "and N more" + -- when the total exceeds the limit +} + +rule MediaLinkToPostSelected { + when: PostPickerResultClicked(media_id, post_id) + let item = media/Media{id: media_id} + ensures: MediaLinkedToPost(item, post/Post{id: post_id}) + -- creates the post-media link and updates sidecar + -- linkedPostIds; the linked posts list refreshes reactively + ensures: PostPickerOverlayClosed(media_id) +} + +rule MediaUnlinkPost { + when: MediaUnlinkPostRequested(media_id, post_id) + let item = media/Media{id: media_id} + -- No confirmation (action_patterns.allium + -- confirmation_assignments: media_unlink) + ensures: MediaUnlinkedFromPost(item, post_id) + -- removes the post-media link row and updates sidecar + -- linkedPostIds } rule MediaTranslationEdit { when: MediaTranslationEditClicked(media_id, language) - -- Opens the "Edit Translation" modal pre-filled with the translation's - -- title, alt, and caption for that language - -- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards + ensures: TranslationEditModalOpened(media_id, language) + -- "Edit Translation" modal pre-filled with the translation's + -- title, alt, and caption; Cancel/close discards +} + +rule MediaTranslationEditSave { + when: TranslationEditModalSaved(media_id, language, title, alt, caption) + let item = media/Media{id: media_id} + ensures: UpsertMediaTranslationRequested(item, language, title, alt, caption) + -- persists the DB record + 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 + let item = media/Media{id: media_id} + requires: active_endpoint_configured + let translated = ai_translate_media_metadata(item, item.language, language) + -- re-translates from the source language via the title model + ensures: UpsertMediaTranslationRequested(item, language, + translated.title, translated.alt, translated.caption) + -- overwrites the existing translation fields and rewrites the + -- translated sidecar } rule MediaTranslationDelete { when: MediaTranslationDeleteClicked(media_id, language) - -- Deletes translation record from DB - -- Deletes translated sidecar file: {path}.{lang}.meta - -- No confirmation dialog + let item = media/Media{id: media_id} + let translation = media/MediaTranslation{media: item, language: language} + -- No confirmation dialog (action_patterns.allium + -- confirmation_assignments: media_translation_delete) + ensures: not exists translation + ensures: MediaTranslationDeleted(item, language) + -- engine_side_effects.allium DeleteMediaTranslationSideEffects + -- deletes the translated sidecar {path}.{lang}.meta } diff --git a/specs/editor_post.allium b/specs/editor_post.allium index c024373..2e578eb 100644 --- a/specs/editor_post.allium +++ b/specs/editor_post.allium @@ -114,6 +114,8 @@ surface PostEditorSurface { 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. @@ -207,119 +209,136 @@ invariant PostEditorModePersistence { 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 config.post_content_sample_length 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 + 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) - -- 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) + 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) } -rule PostAutoTranslateOnSave { - when: PostSaved(post_id) - -- Only an explicit manual save (or publish) triggers this. Auto-saves and - -- post creation (sidebar, deeplink/bookmarklet, import, scripting) never do. - -- 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 -} +-- 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) - -- 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 + 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) - -- Only available for published posts with pending draft changes - -- System confirm dialog: "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 + 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) - -- System confirm dialog: "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 + 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 - -- 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 + -- 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) - -- Opens InsertMediaModal (media search variant) - -- Search input, grid of media items with bds-thumb:// thumbnails - -- Click inserts markdown: - -- Images: ![alt](/media/YYYY/MM/file) - -- Non-images: [originalName](/media/YYYY/MM/file) + 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) - -- 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 + 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) - -- 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: ![](/media/YYYY/MM/file) - -- 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. + 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) - -- Gate: airplane mode check - -- Sends content sample to title model - -- Auto-sets post language field (no modal) - -- Triggers auto-save + 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) } diff --git a/specs/editor_script.allium b/specs/editor_script.allium index 01196d8..c211cbe 100644 --- a/specs/editor_script.allium +++ b/specs/editor_script.allium @@ -70,40 +70,66 @@ surface ScriptEditorSurface { rule ScriptSave { when: ScriptSaveRequested(script_id) - -- Lua syntax check first using the configured runtime semantics - -- If syntax error: blocks save, shows error inline in editor gutter - -- If valid: bumps version, saves to DB + rewrites the published script file - -- Entrypoint list re-discovered from Lua source after save + let target = script/Script{id: script_id} + let syntax = ValidateScript(editor_content(script_id)) + -- editor_content: the current code editor buffer + ensures: + if syntax = valid: + UpdateScriptRequested(target, editor_field_changes(script_id)) + -- Version bump + DB write + published-file rewrite: + -- script.allium UpdateScript. The entrypoint select is + -- re-populated from functions discovered in the Lua source. + else: + ScriptSyntaxErrorsShown(script_id, syntax) + -- Save blocked; errors shown inline in the editor gutter } rule ScriptPublish { when: ScriptPublishRequested(script_id) - -- Only offered while the script is a draft (can_publish gate on the button) - -- Validates Lua syntax first (publish gate); invalid source blocks publish - -- Saves current draft fields, then publishes (status -> published) - -- Writes the published script file to disk - -- See script.allium PublishScript + let target = script/Script{id: script_id} + requires: target.status = draft + -- can_publish gate on the button + let syntax = ValidateScript(editor_content(script_id)) + ensures: + if syntax = valid: + UpdateScriptRequested(target, editor_field_changes(script_id)) + PublishScriptRequested(target) + -- status -> published + file write: script.allium PublishScript + else: + ScriptSyntaxErrorsShown(script_id, syntax) } rule ScriptCheckSyntax { when: ScriptCheckSyntaxRequested(script_id) - -- Validates Lua syntax without saving - -- Shows errors inline in editor gutter - -- Success shown as toast or status indicator + -- Validation only, never saves + let syntax = ValidateScript(editor_content(script_id)) + ensures: + if syntax = valid: + ScriptSyntaxOkShown(script_id) + -- toast / status indicator + else: + ScriptSyntaxErrorsShown(script_id, syntax) } rule ScriptRun { when: ScriptRunRequested(script_id) - -- Executes the script in the configured Lua runtime - -- Calls the configured Lua entrypoint function - -- stdout/stderr directed to Output panel tab - -- Output panel auto-opens if not already visible - -- Errors shown in Output panel with line numbers + let target = script/Script{id: script_id} + ensures: ExecuteScriptRequested(target, editor_entrypoint(script_id), + empty_args(), output_panel_sink()) + -- Runs the current buffer's entrypoint in the sandboxed Lua + -- runtime (script.allium ScriptRuntimeSurface) + ensures: OutputPanelOpened() + -- stdout/stderr and errors (with line numbers) stream to the + -- Output panel tab, which auto-opens when not visible } rule ScriptDelete { when: ScriptDeleteRequested(script_id) - -- No confirmation dialog - -- Deletes DB record + published script file on disk - -- Closes script tab, sidebar removes item + let target = script/Script{id: script_id} + -- No confirmation dialog (action_patterns.allium + -- confirmation_assignments: script_delete -> none) + ensures: DeleteScriptRequested(target) + -- DB record + published script file: script.allium DeleteScript + ensures: closeTab(script_id) + -- sidebar removes the item reactively } diff --git a/specs/editor_template.allium b/specs/editor_template.allium index 5fc8141..9c6a5a4 100644 --- a/specs/editor_template.allium +++ b/specs/editor_template.allium @@ -40,6 +40,7 @@ surface TemplateEditorSurface { when editor.can_publish TemplateValidateRequested(editor.template_id) TemplateDeleteRequested(editor.template_id) + TemplateForceDeleteConfirmed(editor.template_id) @guarantee HeaderLayout -- Header bar with template title tab. @@ -65,36 +66,75 @@ surface TemplateEditorSurface { 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 + let target = template/Template{id: template_id} + let validation = ValidateLiquid(editor_content(template_id)) + -- editor_content: the current code editor buffer + ensures: + if validation = valid: + UpdateTemplateRequested(target, editor_field_changes(template_id)) + -- Version bump + DB write + .liquid rewrite: + -- template.allium UpdateTemplate, + -- engine_side_effects.allium UpdateTemplateSideEffects + else: + TemplateValidationErrorsShown(template_id, validation) + -- Save blocked; errors shown inline in the editor } rule TemplatePublish { when: TemplatePublishRequested(template_id) - -- Only offered while the template is a draft (can_publish gate on the button) - -- Validates Liquid first (publish gate); invalid source blocks publish - -- Saves current draft fields, then publishes (status -> published) - -- Writes the published .liquid file to disk - -- See template.allium PublishTemplate + let target = template/Template{id: template_id} + requires: target.status = draft + -- can_publish gate on the button + let validation = ValidateLiquid(editor_content(template_id)) + ensures: + if validation = valid: + UpdateTemplateRequested(target, editor_field_changes(template_id)) + PublishTemplateRequested(target) + -- status -> published + .liquid file write: + -- template.allium PublishTemplate + else: + TemplateValidationErrorsShown(template_id, validation) } rule TemplateValidate { when: TemplateValidateRequested(template_id) - -- Validates Liquid syntax without saving - -- Shows errors inline in editor - -- Success shown as toast or status indicator + -- Validation only, never saves + let validation = ValidateLiquid(editor_content(template_id)) + ensures: + if validation = valid: + TemplateValidationOkShown(template_id) + -- toast / status indicator + else: + TemplateValidationErrorsShown(template_id, validation) } rule TemplateDelete { when: TemplateDeleteRequested(template_id) - -- Checks for references: posts using this template, tags with postTemplateSlug - -- If references exist: system confirm dialog - -- "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 + let target = template/Template{id: template_id} + let references = target.referencing_posts.count + target.referencing_tags.count + ensures: + if references > 0: + TemplateForceDeleteConfirmShown(template_id) + -- system confirm dialog: "This template is used by N posts + -- and M tags. Force delete?" (action_patterns.allium + -- confirmation_assignments: template_delete_with_references) + else: + DeleteTemplateRequested(target) + closeTab(template_id) + -- no confirmation when nothing references the template +} + +rule TemplateForceDelete { + when: TemplateForceDeleteConfirmed(template_id) + let target = template/Template{id: template_id} + -- Clear references first so template.allium DeleteTemplate's + -- zero-reference precondition holds: + for p in target.referencing_posts: + ensures: p.template_slug = null + for t in target.referencing_tags: + ensures: t.post_template_slug = null + ensures: DeleteTemplateRequested(target) + -- DB record + .liquid file on disk + ensures: closeTab(template_id) + -- sidebar removes the item reactively } diff --git a/specs/i18n.allium b/specs/i18n.allium index bca240e..d5d1d3b 100644 --- a/specs/i18n.allium +++ b/specs/i18n.allium @@ -21,34 +21,85 @@ config { default_language: String = "en" } -invariant SplitLocalization { - -- Two independent locale scopes: - -- 1. UI locale: follows OS system locale - -- 2. Content/render locale: follows project settings (mainLanguage) - -- These are resolved independently and may differ +-- ─── Split localization ───────────────────────────────────── +-- +-- Two independent locale scopes exist per running app. They are +-- resolved by separate rules from separate inputs and may differ. + +entity LocaleScopes { + -- Singleton holding the two locale scopes. + ui_locale: String + -- Follows the OS system locale; used for menus and app chrome + render_locale: String + -- Follows the active project's mainLanguage; used for + -- template rendering and generated content } -invariant LanguageNormalization { - -- Input language codes are normalized: - -- Take base language code (split on '-'): "en-US" -> "en" - -- Fall back to "en" if unrecognized +surface LocaleSurface { + facing _: LocaleConsumer + + provides: + OsLocaleDetected(os_locale) + ProjectMainLanguageResolved(main_language) + MenuTranslationRequested(item_key) + RenderTranslationRequested(label_key) } -invariant MenuTranslations { - -- Menu item labels are separately translatable - -- translateMenu() uses UI locale (system/OS locale), NOT render locale - -- This follows the OS convention: menus match the system language +-- ─── Language normalization ───────────────────────────────── +-- +-- Both scopes normalize their input the same way: take the base +-- language code, fall back to the default when unsupported. + +rule NormalizeUiLocale { + when: OsLocaleDetected(os_locale) + let base = base_language_code(os_locale) + -- black box: substring of the input before the first "-" + -- ("en-US" -> "en") + let codes = config.supported_languages -> code + ensures: + if base in codes: + LocaleScopes.ui_locale = base + else: + LocaleScopes.ui_locale = config.default_language } -invariant RenderTranslations { +rule NormalizeRenderLocale { + when: ProjectMainLanguageResolved(main_language) + let base = base_language_code(main_language) + let codes = config.supported_languages -> code + ensures: + if base in codes: + LocaleScopes.render_locale = base + else: + LocaleScopes.render_locale = config.default_language +} + +-- ─── Translation lookup scopes ────────────────────────────── + +rule TranslateMenuLabel { + when: MenuTranslationRequested(item_key) + -- Menus follow the OS convention: labels match the system + -- language (UI locale), never the render locale. + ensures: MenuLabelTranslated(item_key, locale: LocaleScopes.ui_locale) +} + +rule TranslateRenderLabel { + when: RenderTranslationRequested(label_key) -- Template rendering i18n strings (date formats, archive labels, - -- "older posts", "newer posts", etc.) come from locale JSON files - -- translateRender() and getRenderTranslations() provide these + -- "older posts", "newer posts", etc.) come from the locale JSON + -- files for the render locale. + ensures: RenderLabelTranslated(label_key, locale: LocaleScopes.render_locale) } --- Stemmer language support for search (broader than UI languages) +-- ─── Search stemmer coverage ──────────────────────────────── + invariant SnowballStemmerCoverage { - -- 24 languages supported for FTS5 search stemming - -- ISO 639-1 mapped to Snowball stemmer names - -- All 5 UI languages are a subset of stemmer languages + -- ISO 639-1 codes with a dedicated Snowball algorithm + -- (stemmer_for_language in bds-core fts.rs); nb/nn/no share the + -- Norwegian stemmer, unknown codes fall back to English. + -- Every UI language must have real stemming, not the fallback. + let stemmed_codes = {"ar", "da", "de", "el", "en", "es", "fi", "fr", + "hu", "it", "nb", "nn", "no", "nl", "pt", "ro", "ru", "sv", "ta", "tr"} + for lang in config.supported_languages: + lang.code in stemmed_codes } diff --git a/specs/schema.allium b/specs/schema.allium index f907b4e..c04608a 100644 --- a/specs/schema.allium +++ b/specs/schema.allium @@ -684,52 +684,79 @@ surface MigrationVersionSurface { -- ============================================================================ invariant UniqueProjectSlug { - -- projects.slug must be unique across all projects + for a in Projects: + for b in Projects: + a != b implies a.slug != b.slug } invariant UniquePostSlugPerProject { - -- posts.slug must be unique within each project.project_id -- Enforced by: posts_project_slug_idx unique index + for a in Posts: + for b in Posts: + a != b and a.project_id = b.project_id implies a.slug != b.slug } invariant UniqueTranslationPerPostLanguage { - -- post_translations must have unique (translation_for, language) -- Enforced by: post_translations_translation_language_idx + for a in PostTranslations: + for b in PostTranslations: + a != b implies + not (a.translation_for = b.translation_for and a.language = b.language) } invariant UniqueMediaTranslationPerMediaLanguage { - -- media_translations must have unique (translation_for, language) -- Enforced by: media_translations_translation_language_idx + for a in MediaTranslations: + for b in MediaTranslations: + a != b implies + not (a.translation_for = b.translation_for and a.language = b.language) } invariant UniqueTagNamePerProject { - -- tags.name must be unique within each project.project_id -- Enforced by: tags_project_name_idx unique index + -- Case-insensitive: lower() is the SQLite collation applied by the index + for a in Tags: + for b in Tags: + a != b and a.project_id = b.project_id implies lower(a.name) != lower(b.name) } invariant UniqueScriptSlugPerProject { - -- scripts.slug must be unique within each project.project_id -- Enforced by: scripts_project_slug_idx unique index + for a in Scripts: + for b in Scripts: + a != b and a.project_id = b.project_id implies a.slug != b.slug } invariant UniqueTemplateSlugPerProject { - -- templates.slug must be unique within each project.project_id -- Enforced by: templates_project_slug_idx unique index + for a in Templates: + for b in Templates: + a != b and a.project_id = b.project_id implies a.slug != b.slug } invariant UniquePostMediaLink { - -- post_media must have unique (post_id, media_id) pair -- Enforced by: post_media_post_media_idx unique index + for a in PostMediaLinks: + for b in PostMediaLinks: + a != b implies not (a.post_id = b.post_id and a.media_id = b.media_id) } invariant UniqueGeneratedFileHash { - -- generated_file_hashes must have unique (project_id, relative_path) -- Enforced by: generated_file_hashes_project_path_idx unique index + for a in GeneratedFileHashes: + for b in GeneratedFileHashes: + a != b implies + not (a.project_id = b.project_id and a.relative_path = b.relative_path) } invariant UniqueDismissedDuplicatePair { - -- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b) -- Enforced by: dismissed_pairs_idx unique index + for a in DismissedDuplicatePairs: + for b in DismissedDuplicatePairs: + a != b implies + not (a.project_id = b.project_id + and a.post_id_a = b.post_id_a + and a.post_id_b = b.post_id_b) } -- ============================================================================ diff --git a/specs/template.allium b/specs/template.allium index 7dbab57..4a0e298 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -194,75 +194,81 @@ rule RebuildTemplatesFromFiles { ensures: not exists template } --- Exact Liquid subset required (distilled from bundled starter templates) --- No features beyond this list are used. +-- ─── Liquid subset (compatibility contract) ───────────────── +-- +-- Exact Liquid subset required (distilled from bundled starter templates). +-- No features beyond these sets are accepted. Enforcement happens at +-- publish/validation time (ValidateLiquid); anything outside the sets is +-- rejected even though Liquex would otherwise support it as a built-in. + +config { + -- The 5 allowed tag families as their concrete tag names. + -- Whitespace-stripped variants ({%- -%}) of each are also accepted. + -- render takes named parameters: {% render 'partial', param: value %}. + -- NOT allowed: include, capture, case/when, unless, raw, comment, + -- cycle, tablerow, increment, decrement, liquid, echo + liquid_allowed_tags: Set = { + "if", "elsif", "else", "endif", + "for", "endfor", + "assign", + "render" + } + + -- Standard filters (4). + -- default takes a fallback value, append a suffix string. + -- NOT allowed: date, strip_html, truncate, split, join, size (as + -- filter), upcase, downcase, replace, remove, sort, map, where, + -- first, last, reverse, concat, uniq, compact, strip, + -- newline_to_br, json, prepend, and all math filters + liquid_allowed_standard_filters: Set = { + "escape", "url_encode", "default", "append" + } + + -- Custom filters (3): + -- i18n: language — translates a key string for given language + -- markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug, + -- canonical_media_path_by_source_path, language, language_prefix + -- — renders Markdown to HTML with link rewriting (6 arguments) + -- slugify — slugifies a string (tag/category URLs) + liquid_allowed_custom_filters: Set = { + "i18n", "markdown", "slugify" + } + + -- NOT allowed: !=, <, >=, <=, contains. + -- Logical operators or/and, bare-variable truthiness in {% if %}, + -- the special value blank, dot property access, .size on arrays, + -- and bracket map lookups are part of expression syntax, not + -- operator validation. + liquid_allowed_comparison_operators: Set = { "==", ">" } +} + +-- A published template has passed ValidateLiquid, so its content only +-- uses the allowed sets. liquid_tags / liquid_filters / +-- liquid_comparison_operators are black boxes extracting the construct +-- names a template uses. invariant LiquidTagSubset { - -- Only these 5 tags are used: - -- {% if %} / {% elsif %} / {% else %} / {% endif %} - -- {% for %} / {% endfor %} - -- {% assign %} - -- {% render 'partial', named_param: value %} (with named parameters) - -- Whitespace-stripped variants: {%- -%} - -- - -- NOT used: include, capture, case/when, unless, raw, comment, - -- cycle, tablerow, increment, decrement, liquid, echo + for t in Templates: + t.status = published implies + for tag in liquid_tags(effective_template_content(t)): + tag in config.liquid_allowed_tags } invariant LiquidFilterSubset { - -- Standard filters (4): - -- | escape - -- | url_encode - -- | default: fallback_value - -- | append: suffix_string - -- - -- Custom filters (3): - -- | i18n: language — translates a key string for given language - -- | markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug, - -- canonical_media_path_by_source_path, language, language_prefix - -- — renders Markdown to HTML with link rewriting (6 arguments) - -- | slugify — slugifies a string (used for tag/category URLs) - -- - -- Enforced at publish/validation time (LiquidParser.validate); any other - -- filter is rejected even though Liquex would otherwise apply it as a - -- built-in standard filter. - -- - -- NOT used: date, strip_html, truncate, split, join, size (as filter), - -- upcase, downcase, replace, remove, sort, map, where, first, last, - -- reverse, concat, uniq, compact, strip, newline_to_br, json, prepend, - -- and all math filters + let allowed = config.liquid_allowed_standard_filters + + config.liquid_allowed_custom_filters + for t in Templates: + t.status = published implies + for f in liquid_filters(effective_template_content(t)): + f in allowed } invariant LiquidOperatorSubset { - -- Comparison: ==, > - -- Logical: or, and - -- Truthy/falsy: bare variable in {% if variable %} - -- Special values: blank (nil/empty comparison) - -- Property access: dot notation (object.property), .size on arrays, - -- bracket notation for map lookups (map[key]) - -- - -- Enforced at publish/validation time (LiquidParser.validate); any other - -- comparison operator (!=, <, >=, <=, contains) is rejected even though - -- Liquex would otherwise evaluate it. + for t in Templates: + t.status = published implies + for op in liquid_comparison_operators(effective_template_content(t)): + op in config.liquid_allowed_comparison_operators } -invariant LiquidRenderContext { - -- Template rendering context provides these top-level variables: - -- language, language_prefix, html_theme_attribute, - -- page_title, pico_stylesheet_href, - -- blog_languages (array of {is_current, code, flag, href_prefix}), - -- alternate_links (array of {hreflang, href}), - -- menu_items (tree of {href, title, has_children, children}), - -- calendar_initial_year, calendar_initial_month, - -- post (single post context: {title, content, id, slug, show_title}), - -- post_categories, post_tags, tag_color_by_name (map), - -- backlinks (array of {path, display_slug}), - -- day_blocks (array of {show_date_marker, date_label, posts, show_separator}), - -- archive_context ({kind, name, month, year, day}), - -- show_archive_range_heading, min_date, max_date, - -- canonical_post_path_by_slug (map), canonical_media_path_by_source_path (map), - -- post_data_json_by_id (map), - -- is_list_page, is_first_page, is_last_page, - -- has_prev_page, has_next_page, prev_page_href, next_page_href, - -- not_found_message, not_found_back_label -} +-- The top-level variables available to templates at render time are +-- formalized as the RenderContext value records in template_context.allium. diff --git a/specs/tui.allium b/specs/tui.allium index e2f15c1..7cee46e 100644 --- a/specs/tui.allium +++ b/specs/tui.allium @@ -8,11 +8,22 @@ entity TuiState { focus: sidebar | editor selected_index: Integer editing_post: Boolean + editor_preview: Boolean + -- true while the read-only rendered-Markdown preview is shown -- The editor drives the same BDS.UI.PostEditor workflow as the GUI: -- canonical-language edits update the post, other languages update -- translations; publish routes through the same publishing pipeline. } +config { + -- Same preference sections as the settings backends; matches the + -- GUI settings nav minus the GUI-only Style tab. + tui_settings_sections: List = [ + "project", "editor", "content", "ai", + "technology", "publishing", "data", "mcp" + ] +} + surface TuiSurface { facing _: TuiRuntime @@ -21,207 +32,468 @@ surface TuiSurface { TuiEventReceived(entity, entity_id, action) TuiSettingsChanged(key) ShellTaskCompleted(route) + ProjectsOverlaySelectionConfirmed(project) + FolderPathPromptTabPressed(input) + FolderPathPromptSubmitted(path) + SearchPromptChanged(query) + SearchPromptConfirmed(query) + SearchPromptCancelled() + CommandListSelectionConfirmed(command) + GitCommitPromptSubmitted(message) + + @guarantee OverlayPrecedence + -- Open overlays and status-line prompts capture key input + -- before the sidebar/editor key rules below apply. Overlays + -- clear the terminal cells beneath them. + + @guarantee EditorRendering + -- The post editor renders the buffer with syntax highlighting + -- (markdown with [[macro]] accents for posts, HTML+Liquid for + -- templates, Lua for scripts), soft-wraps long lines to the + -- viewport width, keeps the cursor's visual row visible while + -- scrolling, and highlights the cursor's source line. There is + -- no line-number gutter. } +-- ─── Sidebar navigation ───────────────────────────────────── + rule SidebarNavigation { when: TuiKeyPressed(code, _) requires: code = "up" or code = "down" or code = "j" or code = "k" - -- Selection moves over the flattened sidebar items and skips - -- section headers; data comes from BDS.UI.Sidebar.view, identical - -- to the GUI sidebar content. - ensures: TuiState.selected_index.updated() + requires: TuiState.focus = sidebar + let items = flattened_sidebar_items() + -- BDS.UI.Sidebar.view data, identical to the GUI sidebar + ensures: + if code = "up" or code = "k": + TuiState.selected_index = previous_selectable_index(items, TuiState.selected_index) + else: + TuiState.selected_index = next_selectable_index(items, TuiState.selected_index) + -- section headers are not selectable; movement skips them } rule OpenEntry { when: TuiKeyPressed(code, _) requires: code = "enter" - -- Posts open in the editor by default (title + textarea seeded from - -- the persisted form; syntax highlighting and word wrap on; ctrl+e - -- switches to the rendered preview). New empty posts created with - -- "n" open in the editor as well. Images open in the terminal image - -- preview. Other entities report that terminal editing is not - -- available yet. - ensures: TuiState.editing_post.updated() + requires: TuiState.focus = sidebar + let entry = selected_sidebar_entry(TuiState.selected_index) + ensures: + if entry.kind = "post": + TuiState.editing_post = true + TuiState.focus = editor + -- editor seeded from the persisted form (title + textarea); + -- syntax highlighting and word wrap on. New empty posts + -- created with "n" open in the editor the same way. + if entry.kind = "image": + TerminalImagePreviewOpened(entry) + if entry.kind != "post" and entry.kind != "image": + StatusMessageShown(message_key: "tui.editing_not_available") } +-- ─── Post editor keys ─────────────────────────────────────── + rule SaveAndPublish { when: TuiKeyPressed(code, modifiers) requires: code = "s" or code = "p" requires: modifiers = "ctrl" - -- ctrl+s saves the draft, ctrl+p saves and publishes — both through - -- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and - -- the event bus behave exactly as a GUI save. - ensures: PostPersisted() + requires: TuiState.editing_post + ensures: + if code = "s": + PostDraftPersisted() + else: + PostPersistedAndPublished() + -- Both persist through BDS.UI.PostEditor.Persistence, so files, + -- embeddings, search index, and the event bus behave exactly as a + -- GUI save. } rule EditorMode { when: TuiKeyPressed(code, modifiers) requires: code = "e" requires: modifiers = "ctrl" - -- ctrl+e toggles between the syntax-highlighted editor (the default - -- mode when opening a post) and the read-only rendered-Markdown - -- preview (rendered through tui-markdown so headings/emphasis/lists - -- are styled); keys in preview never edit content, and esc returns - -- to the sidebar from either mode. - ensures: PreviewToggled() + requires: TuiState.editing_post + ensures: TuiState.editor_preview = not TuiState.editor_preview + -- toggles between the syntax-highlighted editor (the default + -- when opening a post) and the read-only rendered-Markdown + -- preview (tui-markdown: headings/emphasis/lists styled); + -- keys in preview never edit content } -rule CodeEditorWrapping { - when: TuiState.editing_post.updated() - -- The post editor renders the buffer with syntax highlighting - -- (markdown with [[macro]] accents for posts, HTML+Liquid for - -- templates, Lua for scripts), soft-wraps long lines to the - -- viewport width, keeps the cursor's visual row visible while - -- scrolling, and highlights the cursor's source line. There is no - -- line-number gutter. - ensures: TuiState.updated() +rule EditorEscape { + when: TuiKeyPressed(code, _) + requires: code = "esc" + requires: TuiState.focus = editor + ensures: TuiState.focus = sidebar + -- esc returns to the sidebar from either editor mode } rule AiQuickAction { when: TuiKeyPressed(code, modifiers) requires: code = "g" requires: modifiers = "ctrl" - -- One-shot AI post analysis (title/excerpt suggestions). Gated by - -- airplane mode: without a local endpoint the TUI shows a status - -- message instead of calling out. - ensures: AiSuggestionsRequestedOrRefused() + requires: TuiState.editing_post + ensures: + if active_endpoint_configured: + AISuggestionRequested(entity_type: "post", entity_id: edited_post_id()) + -- one-shot AI post analysis (title/excerpt suggestions) + else: + StatusMessageShown(message_key: "tui.ai_unavailable_offline") + -- airplane gating: without a local endpoint the TUI shows + -- a status message instead of calling out } +-- ─── Project switcher ─────────────────────────────────────── + rule ProjectSwitcher { - when: TuiKeyPressed(code, _) + when: TuiKeyPressed(code, modifiers) requires: code = "p" - -- "p" opens the projects overlay: all projects from the caching - -- database with the active one marked; enter activates the selected - -- project (BDS.Projects.set_active_project) and reloads the sidebar. - -- "o" switches to a folder-path prompt with bash-style tab - -- completion: tab completes a unique directory (trailing slash - -- appended), an ambiguous prefix completes to the longest common - -- prefix and lists the candidate folders; only directories are - -- offered, dotfolders only for a dot-prefixed input, and a leading - -- "~" expands to the home directory. A typed path is validated - -- to be an existing directory, then opened as a project - -- (BDS.Projects.create_project with data_path, named after the - -- folder) and activated; a full database rebuild is queued - -- automatically through the rebuild_database shell command so the - -- folder's content is loaded into the caching database. Invalid - -- paths keep the prompt open and report the error. - ensures: ProjectSwitchedOrOpened() + requires: modifiers != "ctrl" + requires: TuiState.focus = sidebar + ensures: ProjectsOverlayOpened(projects: all_projects(), active: active_project()) + -- all projects from the caching database, active one marked +} + +rule ProjectOverlayActivate { + when: ProjectsOverlaySelectionConfirmed(project) + ensures: ActiveProjectChanged(project) + -- BDS.Projects.set_active_project + ensures: SidebarReloaded() +} + +rule ProjectOverlayFolderPrompt { + when: TuiKeyPressed(code, _) + requires: code = "o" + requires: projects_overlay_open() + ensures: FolderPathPromptOpened() + -- switches the overlay to a folder-path prompt with bash-style + -- tab completion +} + +rule FolderPromptTabCompletion { + when: FolderPathPromptTabPressed(input) + let expanded = expand_home(input) + -- a leading "~" expands to the home directory + let candidates = completion_candidates(expanded) + -- only directories are offered; dotfolders only when the last + -- path segment starts with "." + ensures: + if candidates.count = 1: + FolderPromptInputReplaced(candidates.first + "/") + -- a unique directory completes with a trailing slash + if candidates.count > 1: + FolderPromptInputReplaced(longest_common_prefix(candidates)) + FolderPromptCandidatesListed(candidates) + -- ambiguous prefix: longest common prefix + candidate list + if candidates.count = 0: + FolderPromptUnchanged() +} + +rule FolderPromptSubmit { + when: FolderPathPromptSubmitted(path) + let expanded = expand_home(path) + ensures: + if directory_exists(expanded): + ProjectOpenedFromFolder(expanded) + -- BDS.Projects.create_project with data_path, named after + -- the folder, then activated + DatabaseRebuildQueued(expanded) + -- queued automatically through the rebuild_database shell + -- command so the folder's content is loaded into the + -- caching database + else: + FolderPromptErrorShown(path) + -- invalid paths keep the prompt open and report the error +} + +-- ─── Sidebar search ───────────────────────────────────────── + +enum TuiSearchTokenKind { + text + -- plain word: text search + tag_filter + -- "tag:x": filters like the GUI tag chip + category_filter + -- "category:x": filters like the GUI category chip + day_filter + -- ISO date (yyyy-mm-dd): narrows to that day; extends the + -- shared sidebar queries +} + +value TuiSearchToken { + kind: TuiSearchTokenKind + argument: String + -- the search word, tag/category name, or ISO date +} + +surface TuiSearchTokenSurface { + context token: TuiSearchToken + + exposes: + token.kind + token.argument } rule SidebarSearch { when: TuiKeyPressed(code, _) requires: code = "/" - -- "/" in sidebar focus opens a vi-style search prompt in the status - -- line that live-filters the current view through the same - -- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages, - -- media). Plain words are a text search, "tag:x" and "category:x" - -- filter like the GUI chips, and an ISO date (yyyy-mm-dd) narrows - -- to that day — the day filter extends the shared sidebar queries. - -- Tokens combine with AND. Enter keeps the filter (shown appended - -- to the sidebar title), esc clears it; filters are per view. - ensures: SidebarFiltered() + requires: TuiState.focus = sidebar + ensures: SearchPromptOpened() + -- vi-style search prompt in the status line } +rule SidebarSearchInput { + when: SearchPromptChanged(query) + let tokens = parse_search_tokens(query) + -- black box: each whitespace-separated token classified as a + -- TuiSearchToken; tokens combine with AND + ensures: SidebarFiltered(filters: tokens) + -- live-filters the current view through the same shared + -- BDS.UI.Sidebar filter params the GUI sidebar uses + -- (posts, pages, media) +} + +rule SidebarSearchCommit { + when: SearchPromptConfirmed(query) + ensures: SidebarFilterKept(view: TuiState.view, query: query) + -- enter keeps the filter, shown appended to the sidebar title; + -- filters are per view +} + +rule SidebarSearchCancel { + when: SearchPromptCancelled() + ensures: SidebarFilterCleared(view: TuiState.view) + -- esc clears the filter +} + +-- ─── Command prompt ───────────────────────────────────────── + rule CommandPrompt { when: TuiKeyPressed(code, _) requires: code = ":" - -- Vi-style prompt: ":" opens a filterable list of the parameterless - -- Blog-menu shell commands (metadata diff, validate site, force - -- render, rebuilds, reindex, translations, duplicates, upload, - -- browser preview URL) and runs the selection through the same - -- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?" - -- shows the full list as help. Overlays clear the cells beneath - -- them. Commands whose follow-up UI is GUI-only (validate - -- translations, find duplicates) are marked. Queued tasks report - -- progress in the status line until all tasks finish. - ensures: CommandListShownOrExecuted() + requires: TuiState.focus = sidebar + ensures: CommandListOpened(commands: parameterless_shell_commands()) + -- vi-style prompt: filterable list of the parameterless + -- Blog-menu shell commands (metadata diff, validate site, + -- force render, rebuilds, reindex, translations, duplicates, + -- upload, browser preview URL) — the same + -- BDS.Desktop.ShellCommands backend as the GUI menu. ":?" + -- shows the full list as help. Commands whose follow-up UI is + -- GUI-only (validate translations, find duplicates) are marked. } +rule CommandPromptExecute { + when: CommandListSelectionConfirmed(command) + ensures: ShellCommandExecuted(command) + ensures: TaskProgressShownInStatusLine() + -- queued tasks report progress in the status line until all + -- tasks finish +} + +-- ─── Settings panel (issue #29) ───────────────────────────── + rule SettingsPanel { when: TuiKeyPressed(code, _) requires: code = "6" - -- "6" opens the settings panel (issue #29), matching the GUI panel - -- numbering: the sidebar lists the same preference sections as the - -- settings backends (Project, Editor, Content, AI, Technology, - -- Publishing, Data, MCP). The GUI-only Style tab is intentionally - -- absent. Enter on a section opens a section-specific editor in the - -- main area: a generic typed-field form from BDS.UI.SettingsForm. - -- Enter edits a text field in a status-line prompt, toggles a - -- boolean, or cycles an enum through its options; read-only info - -- rows are skipped by the selection. ctrl+s saves the section - -- through the same backends as the GUI editor (BDS.Metadata, - -- BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) and reloads the form; - -- esc cancels the prompt, then closes the form. Category removal - -- and AI model discovery remain GUI-only. - ensures: SettingsFormShownEditedOrSaved() + requires: TuiState.focus = sidebar + -- "6" matches the GUI panel numbering + ensures: TuiState.view = settings + ensures: SettingsSectionsListed(sections: config.tui_settings_sections) + -- enter on a section opens a section-specific editor in the + -- main area: a generic typed-field form from BDS.UI.SettingsForm } +rule SettingsFieldEdit { + when: TuiKeyPressed(code, _) + requires: code = "enter" + requires: TuiState.view = settings + let row = selected_settings_row() + requires: not row.read_only + -- read-only info rows are skipped by the selection + ensures: + if row.kind = "text": + StatusLinePromptOpened(row) + -- esc cancels the prompt, then closes the form + if row.kind = "boolean": + SettingToggled(row) + if row.kind = "enum": + SettingCycled(row) + -- cycles the enum through its options +} + +rule SettingsSave { + when: TuiKeyPressed(code, modifiers) + requires: code = "s" + requires: modifiers = "ctrl" + requires: TuiState.view = settings + ensures: SettingsSectionSaved(selected_settings_section()) + -- saves through the same backends as the GUI editor + -- (BDS.Metadata, BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) + -- and reloads the form. Category removal and AI model + -- discovery remain GUI-only. +} + +-- ─── Tags panel (issue #34) ───────────────────────────────── + rule TagsPanel { when: TuiKeyPressed(code, _) requires: code = "5" - -- "5" opens the tags view (issue #34), matching the GUI panel - -- numbering. The sidebar lists the same three sections as the GUI - -- tags editor (Tag Cloud, Create / Edit, Merge Tags — from - -- BDS.UI.Sidebar's tags nav view); enter opens the section in the - -- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags - -- operations as the GUI editor. Cloud lists tags with their post - -- usage counts ordered by usage; manage is alphabetical with "n" - -- (create prompt), enter (rename prompt), "c" (cycle colour through - -- the shared presets), "t" (cycle post template), "d" then "y" - -- (delete with post-count confirmation; any other key cancels) and - -- "s" (sync tags from post tags); merge marks tags with space and - -- "m" merges the marked tags into the selected one (at least two, - -- target included). Text prompts live in the status line; esc - -- cancels the prompt, then closes the panel. Domain tag events - -- reload an open panel, keeping it in sync with GUI and CLI writes. - ensures: TagsPanelShownOrEdited() + requires: TuiState.focus = sidebar + -- "5" matches the GUI panel numbering + ensures: TuiState.view = tags + ensures: TagsSectionsListed() + -- the same three sections as the GUI tags editor (Tag Cloud, + -- Create / Edit, Merge Tags — from BDS.UI.Sidebar's tags nav + -- view); enter opens the section in the main area, all backed + -- by BDS.UI.TagsPanel over the same BDS.Tags operations as the + -- GUI editor. Cloud lists tags with post usage counts ordered + -- by usage; manage is alphabetical. Text prompts live in the + -- status line; esc cancels the prompt, then closes the panel. + -- Domain tag events reload an open panel, keeping it in sync + -- with GUI and CLI writes. } +rule TagsManageKeys { + when: TuiKeyPressed(code, _) + requires: TuiState.view = tags + requires: tags_manage_section_active() + ensures: + if code = "n": + TagCreatePromptOpened() + if code = "enter": + TagRenamePromptOpened(selected_tag()) + if code = "c": + TagColourCycled(selected_tag()) + -- cycles through the shared colour presets + if code = "t": + TagTemplateCycled(selected_tag()) + -- cycles the tag's post template + if code = "d": + TagDeleteConfirmArmed(selected_tag()) + -- "d" then "y" deletes with post-count confirmation; + -- any other key cancels + if code = "s": + TagsSyncedFromPosts() +} + +rule TagsMergeKeys { + when: TuiKeyPressed(code, _) + requires: TuiState.view = tags + requires: tags_merge_section_active() + ensures: + if code = "space": + TagMarkToggled(selected_tag()) + if code = "m" and marked_tags().count >= 2: + TagsMerged(source_tags: marked_tags(), target_tag: selected_tag()) + -- merges the marked tags into the selected one (target + -- included, at least two marked) +} + +-- ─── Git panel (issue #30) ────────────────────────────────── + rule GitPanel { when: TuiKeyPressed(code, _) requires: code = "7" - -- "7" opens the git panel (issue #30) for content sync: the sidebar - -- lists the changed files from git status (code + path, branch with - -- ahead/behind counts in the title) with the commit history in its - -- lower half, like the GUI (short hash + subject; ↑ marks commits - -- that still need a push, ↓ remote-only ones), the main area shows a - -- scrollable whole-folder diff (staged + unstaged, capped) paged - -- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a - -- status-line commit prompt (git add -A + commit, empty messages - -- rejected), "u" pulls (ff-only) and "s" pushes — both run off the - -- UI process and report back as a status toast. Every action is - -- BDS.Git, the same backend as the GUI git panel. A project folder - -- that is not a git repository shows a message and refuses the - -- actions. - ensures: GitPanelShownOrSynced() + requires: TuiState.focus = sidebar + -- "7" matches the GUI panel numbering; content sync panel + ensures: TuiState.view = git + ensures: GitPanelOpened() + -- sidebar: changed files from git status (code + path, branch + -- with ahead/behind counts in the title), commit history in + -- the lower half like the GUI (short hash + subject; ↑ marks + -- commits that still need a push, ↓ remote-only ones). + -- Main area: scrollable whole-folder diff (staged + unstaged, + -- capped) paged with pgup/pgdn. Every action is BDS.Git, the + -- same backend as the GUI git panel. } +rule GitPanelKeys { + when: TuiKeyPressed(code, _) + requires: TuiState.view = git + requires: is_git_repository(active_project()) + ensures: + if code = "enter": + GitDiffJumpedToFile(selected_changed_file()) + if code = "c": + GitCommitPromptOpened() + -- status-line commit prompt + if code = "u": + GitPullStarted() + -- ff-only, runs off the UI process, reports back as a + -- status toast + if code = "s": + GitPushStarted() + -- runs off the UI process, reports back as a status toast +} + +rule GitPanelNonRepo { + when: TuiKeyPressed(code, _) + requires: TuiState.view = git + requires: not is_git_repository(active_project()) + requires: code = "c" or code = "u" or code = "s" + ensures: StatusMessageShown(message_key: "tui.not_a_git_repository") + -- a project folder that is not a git repository shows a + -- message and refuses the actions +} + +rule GitCommitSubmit { + when: GitCommitPromptSubmitted(message) + ensures: + if message = "": + GitCommitRejectedEmptyMessage() + -- empty messages are rejected + else: + GitCommitExecuted(message) + -- git add -A + commit +} + +-- ─── Report panels ────────────────────────────────────────── + rule ReportPanels { when: ShellTaskCompleted(route) requires: route = "metadata_diff" or route = "site_validation" - -- Completed metadata diff and site validation tasks open a - -- scrollable report panel showing the differences. Enter applies - -- the whole report — metadata diff repairs all items from the - -- filesystem (file → db) and imports orphan files; site validation - -- applies the full report incrementally (render missing, remove - -- extra, re-render updated), matching the GUI defaults. Esc closes - -- the report without applying. Reports completed before this - -- session started never pop up. - ensures: ReportShownThenAppliedOrCancelled() + requires: task_completed_in_current_session(route) + -- reports completed before this session started never pop up + ensures: ReportPanelOpened(route) + -- scrollable report panel showing the differences } +rule ReportPanelApply { + when: TuiKeyPressed(code, _) + requires: code = "enter" + requires: report_panel_open() + ensures: ReportApplied(open_report()) + -- enter applies the whole report: metadata diff repairs all + -- items from the filesystem (file -> db) and imports orphan + -- files; site validation applies the full report incrementally + -- (render missing, remove extra, re-render updated) — matching + -- the GUI defaults + ensures: ReportPanelClosed() +} + +rule ReportPanelDismiss { + when: TuiKeyPressed(code, _) + requires: code = "esc" + requires: report_panel_open() + ensures: ReportPanelClosed() + -- esc closes the report without applying +} + +-- ─── Multi-client sync ────────────────────────────────────── + rule MultiClientSync { when: TuiEventReceived(entity, entity_id, action) - -- Domain events refresh the sidebar; a post deleted elsewhere closes - -- the open editor. Keeps TUI sessions synchronized with GUI clients - -- and pipeline ingestion. - ensures: TuiState.updated() + -- Keeps TUI sessions synchronized with GUI clients and pipeline + -- ingestion. + ensures: SidebarReloaded() + ensures: + if entity = "post" and action = "deleted" and edited_post_id() = entity_id: + TuiState.editing_post = false + -- a post deleted elsewhere closes the open editor } rule ServerSideLocale { when: TuiSettingsChanged(key) requires: key = "ui.language" - -- The TUI re-reads the server-side UI language and re-renders; all - -- clients always share one language. ensures: TuiRelocalized() + -- the TUI re-reads the server-side UI language and re-renders; + -- all clients always share one language }