Formalize tier-1 comment-based specs into allium constructs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bauer, Georg
2026-07-22 14:58:22 +02:00
parent 1dd8194fa8
commit 733aa9a6ff
9 changed files with 1088 additions and 471 deletions

View File

@@ -15,114 +15,215 @@ surface UserAction {
provides: AISuggestionRequested(entity_type, entity_id) provides: AISuggestionRequested(entity_type, entity_id)
provides: PostSaved(post_id) provides: PostSaved(post_id)
provides: PostAutoTranslateCompleted(post_id, language) 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 { provides: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions)
-- All AI operations route through the active endpoint for the current mode. -- The active endpoint's response, parsed into per-field suggestions
-- 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
} }
-- ─── 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. rule AiOperationRefusedWithoutEndpoint {
-- See modals.allium AISuggestionsModal value type.
rule AISuggestionFlow {
when: AISuggestionRequested(entity_type, entity_id) when: AISuggestionRequested(entity_type, entity_id)
-- 1. Check AIOperationGating (abort if offline and no local model) requires: not active_endpoint_configured
-- 2. Send request to appropriate model: ensures: ToastShown(message_key: "ai.unavailable_configure_endpoint")
-- Posts: title model, input = title + excerpt + first 2000 chars -- "AI unavailable — configure {online|airplane} endpoint in Settings"
-- Media: image model, input = 448x448 AI thumbnail JPEG ensures: AiOperationAborted(entity_type, entity_id)
-- 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 ──────────────────────────── -- ─── 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 { rule AutoTranslationChain {
when: PostSaved(post_id) when: PostSaved(post_id)
-- Gate: AIOperationGating + post.doNotTranslate must be false -- Triggered only by explicit manual save or publish, never by
-- Triggered only by explicit manual save or publish, never by auto-save, -- auto-save, unmount/tab-switch persistence, post creation, import,
-- unmount/tab-switch persistence, post creation, import, or scripting. -- or scripting.
-- For each configured blogLanguage missing a translation for this post: let saved_post = post/Post{id: post_id}
-- 1. Enqueue one background task for that language requires: active_endpoint_configured
-- 2. Translate metadata (title, excerpt) via title model requires: not saved_post.do_not_translate
-- 3. Translate content (full markdown) via title model let missing = saved_post.project.blog_languages - saved_post.available_languages
-- 4. Create/update translation record in DB for language in missing:
-- A later manual save with no missing languages enqueues no task. ensures: PostTranslationTaskEnqueued(post_id, language)
-- Work is sequential within a language and parallel across languages. -- One background task per missing language. Each task:
-- Progress visible in Tasks panel -- 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 { rule MediaMetadataTranslationCascade {
when: PostAutoTranslateCompleted(post_id, language) when: PostAutoTranslateCompleted(post_id, language)
-- After a post translation completes for a given language: let saved_post = post/Post{id: post_id}
-- For each media item linked to this post: for link in saved_post.linked_media:
-- If media has source language set let item = media/Media{id: link.media_id}
-- and no translation exists for {language}: if item.language != null
-- Enqueue background task: translate media metadata and not exists media/MediaTranslation{media: item, language: language}:
-- (title, alt, caption) via title model ensures: MediaMetadataTranslationTaskEnqueued(link.media_id, language)
-- Creates translated sidecar file: {path}.{lang}.meta -- 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. rule PostDragDropImageChain {
-- See editor_post.allium PostDragDropImage for trigger rule. 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): rule DragDropAiAutoApply {
-- 1. importMedia(file) -> new media record + file copy + base sidecar when: DragDropImportCompleted(target, imported)
-- 2. generateThumbnails(media) -> async start (small/medium/large/ai) requires: active_endpoint_configured
-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds -- Background, non-blocking: image model on the 448x448 AI thumbnail.
-- 4. insertMarkdownImage(cursor) -> insert ![](/media/YYYY/MM/file) at cursor -- 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): rule DragDropTranslationCascade {
-- 5. If AI available: aiImageAnalysis(media) when: DragDropImportCompleted(target, imported)
-- Uses image model on 448x448 AI thumbnail requires: active_endpoint_configured
-- Results auto-applied to media metadata (NO modal for drag-drop, requires: not target.do_not_translate
-- unlike manual Quick Action which shows AISuggestionsModal) for language in target.project.blog_languages:
-- Triggers sidecar rewrite ensures: MediaMetadataTranslationTaskEnqueued(imported.id, language)
-- 6. If auto-translate enabled (post.doNotTranslate=false): -- creates translated sidecar files
-- For each blogLanguage: translateMediaMetadata(media, lang) }
-- 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 value ConfirmationAssignment {
-- Simple yes/no system dialog, no custom UI action: String
-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist) pattern: ConfirmationPattern
}
-- Pattern 2: ConfirmDeleteModal (custom modal with reference info) config {
-- Shows entity name, reference counts, linked entity list confirmation_assignments: Set<ConfirmationAssignment> = {
-- Two buttons: Cancel, Delete (destructive red style) ConfirmationAssignment(action: "post_delete", pattern: system_confirm),
-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count) ConfirmationAssignment(action: "post_discard", pattern: system_confirm),
ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm),
-- Pattern 3: ConfirmDialog (custom modal for non-delete confirmations) ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal),
-- Shows description of action and consequences -- shows linked posts
-- Two buttons: Cancel, Confirm ConfirmationAssignment(action: "tag_delete", pattern: confirm_delete_modal),
-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.") -- shows post count
ConfirmationAssignment(action: "tag_merge", pattern: confirm_dialog),
-- Pattern 4: No confirmation (immediate execution) -- "Merge N tags into {target}? Cannot be undone."
-- Action executes on click, no dialog ConfirmationAssignment(action: "rebuild_operations", pattern: none),
-- Used by: all Rebuild operations, ScriptDelete, MenuItemDelete, ConfirmationAssignment(action: "script_delete", pattern: none),
-- MetadataDiff per-field sync, ImportExecute, MediaTranslationDelete, ConfirmationAssignment(action: "menu_item_delete", pattern: none),
-- MediaUnlink 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)
}
}

View File

@@ -7,6 +7,7 @@
-- the main content area when a media tab is active. -- the main content area when a media tab is active.
use "./media.allium" as media use "./media.allium" as media
use "./post.allium" as post
use "./i18n.allium" as i18n use "./i18n.allium" as i18n
-- ─── Media editor ───────────────────────────────────────────── -- ─── Media editor ─────────────────────────────────────────────
@@ -56,6 +57,9 @@ surface PostPickerOverlaySurface {
result.post_id result.post_id
result.title result.title
overlay.overflow_count when overlay.overflow_count != null overlay.overflow_count when overlay.overflow_count != null
provides:
PostPickerResultClicked(media_id, post_id)
} }
value PostPickerResult { value PostPickerResult {
@@ -103,6 +107,8 @@ surface MediaEditorSurface {
MediaTranslationRefreshClicked(editor.media_id, language) MediaTranslationRefreshClicked(editor.media_id, language)
MediaTranslationDeleteClicked(editor.media_id, language) MediaTranslationDeleteClicked(editor.media_id, language)
MediaUnlinkPostRequested(editor.media_id, post_id) MediaUnlinkPostRequested(editor.media_id, post_id)
MediaDeleteConfirmed(editor.media_id)
TranslationEditModalSaved(editor.media_id, language, title, alt, caption)
@guarantee HeaderLayout @guarantee HeaderLayout
-- Header bar with media display name. -- Header bar with media display name.
@@ -162,84 +168,153 @@ surface MediaEditorSurface {
-- ─── Media editor actions ───────────────────────────────────── -- ─── 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 { rule MediaAIImageAnalysis {
when: MediaAIImageAnalysisRequested(media_id) when: MediaAIImageAnalysisRequested(media_id)
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating) -- Only available for image/* MIME types (editor.is_image guard
-- Only available for image/* MIME types (button hidden for non-images) -- on the provides entry; button hidden for non-images).
-- Uses image analysis model (vision-capable, not title model) ensures: AISuggestionRequested(entity_type: "media", entity_id: media_id)
-- Input: AI-optimized JPEG thumbnail (448x448, generated on import) -- Shared flow: action_patterns.allium MediaAISuggestionFlow —
-- Response: suggested title, alt text, caption -- airplane gating, image model over the 448x448 AI thumbnail,
-- Opens AISuggestionsModal with 3 fields (title, alt, caption) -- AISuggestionsModal with title/alt/caption, accepted fields
-- On confirm: applies checked fields, triggers explicit save -- trigger an explicit save.
} }
rule MediaDetectLanguage { rule MediaDetectLanguage {
when: MediaDetectLanguageRequested(media_id) when: MediaDetectLanguageRequested(media_id)
-- Gate: airplane mode check let item = media/Media{id: media_id}
-- Input: concatenation of title + alt + caption text requires: active_endpoint_configured
-- Response: detected language code -- airplane-mode gating: ai.allium AirplaneModeGating
-- Immediately persists to media record (no modal, no confirmation) let detected = ai_detect_language(concat(item.title, item.alt, item.caption))
-- Triggers sidecar rewrite -- 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 { rule MediaTranslateMetadata {
when: MediaTranslateMetadataRequested(media_id, target_language) when: MediaTranslateMetadataRequested(media_id, target_language)
-- Gate: airplane mode check let item = media/Media{id: media_id}
-- Opens language picker modal (same pattern as post translate) requires: active_endpoint_configured
-- Two-step process: -- target_language comes from the language picker modal (same
-- 1. If source language not set: detect it first (auto-persist) -- pattern as post translate).
-- 2. Translate title, alt, caption to target language via title model let source_language = item.language
-- Creates/updates media translation record ?? ai_detect_language(concat(item.title, item.alt, item.caption))
-- Writes translated sidecar file: {path}.{lang}.meta -- 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 { rule MediaReplaceFile {
when: MediaReplaceFileRequested(media_id) when: MediaReplaceFileRequested(media_id)
-- Opens native file dialog (no MIME type filter) let item = media/Media{id: media_id}
-- Copies selected file over existing media file path let source = native_file_dialog_selection()
-- If image: regenerates thumbnails synchronously (awaited) -- native file dialog, no MIME type filter; cancel aborts
-- Preview area updates with cache-busting timestamp query param 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 { rule MediaDeleteAction {
when: MediaDeleteRequested(media_id) when: MediaDeleteRequested(media_id)
-- Opens ConfirmDeleteModal (custom modal, not native dialog) let item = media/Media{id: media_id}
-- Shows: media display name, linked posts count and list ensures: ConfirmDeleteModalOpened(media_id, item.linked_posts)
-- Two buttons: Cancel, Delete (destructive red style) -- custom modal (not a native dialog): media display name,
-- On confirm: deletes file, sidecar, thumbnails, all translations, -- linked post count and list; Cancel + Delete (destructive
-- post-media links, FTS index entry -- red style) — action_patterns.allium
-- Closes media tab, sidebar removes item -- confirmation_assignments: media_delete
-- See engine_side_effects.allium DeleteMediaSideEffects }
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 { rule MediaLinkToPost {
when: MediaLinkToPostRequested(media_id) when: MediaLinkToPostRequested(media_id)
-- Opens inline post picker overlay (not a modal, positioned near button) ensures: PostPickerOverlayOpened(media_id)
-- Search input filtering unlinked posts by title -- inline overlay near the button (not a modal): search input
-- Up to config.media_post_picker_max_results results shown -- filters unlinked posts by title, at most
-- "and N more" text if results exceed limit -- config.media_post_picker_max_results results, "and N more"
-- Click links media to selected post (updates sidecar linkedPostIds) -- when the total exceeds the limit
-- Linked posts list refreshes immediately }
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 { rule MediaTranslationEdit {
when: MediaTranslationEditClicked(media_id, language) when: MediaTranslationEditClicked(media_id, language)
-- Opens the "Edit Translation" modal pre-filled with the translation's ensures: TranslationEditModalOpened(media_id, language)
-- title, alt, and caption for that language -- "Edit Translation" modal pre-filled with the translation's
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards -- 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 { rule MediaTranslationRefresh {
when: MediaTranslationRefreshClicked(media_id, language) when: MediaTranslationRefreshClicked(media_id, language)
-- Gate: airplane mode check let item = media/Media{id: media_id}
-- Re-translates from source language to target via title model requires: active_endpoint_configured
-- Overwrites existing translation fields let translated = ai_translate_media_metadata(item, item.language, language)
-- Rewrites translated sidecar file -- 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 { rule MediaTranslationDelete {
when: MediaTranslationDeleteClicked(media_id, language) when: MediaTranslationDeleteClicked(media_id, language)
-- Deletes translation record from DB let item = media/Media{id: media_id}
-- Deletes translated sidecar file: {path}.{lang}.meta let translation = media/MediaTranslation{media: item, language: language}
-- No confirmation dialog -- 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
} }

View File

@@ -114,6 +114,8 @@ surface PostEditorSurface {
PostGalleryRequested(editor.post_id) PostGalleryRequested(editor.post_id)
ImageDroppedOnEditor(editor.post_id, file_path) ImageDroppedOnEditor(editor.post_id, file_path)
PostLanguageDetectRequested(editor.post_id) PostLanguageDetectRequested(editor.post_id)
PostDiscardConfirmed(editor.post_id)
PostDeleteConfirmed(editor.post_id)
@guarantee HeaderLayout @guarantee HeaderLayout
-- Header bar with two areas. -- Header bar with two areas.
@@ -207,119 +209,136 @@ invariant PostEditorModePersistence {
rule PostAIAnalysis { rule PostAIAnalysis {
when: PostAIAnalysisRequested(post_id) when: PostAIAnalysisRequested(post_id)
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating) ensures: AISuggestionRequested(entity_type: "post", entity_id: post_id)
-- Uses title model (not default chat model) -- Shared flow: action_patterns.allium PostAISuggestionFlow —
-- Input: post title + excerpt + content (first config.post_content_sample_length chars) -- airplane gating, title model (not the default chat model)
-- Response: suggested title, excerpt, slug -- over title + excerpt + content sample, AISuggestionsModal
-- Opens AISuggestionsModal with 3 fields: -- with title/excerpt/slug (slug locked once published),
-- Each field: current value, suggested value, accept checkbox -- accepted fields re-arm the auto-save timer.
-- Slug field locked (no accept checkbox) if post was ever published
-- On confirm: applies only checked fields, triggers auto-save
} }
rule PostTranslateAction { rule PostTranslateAction {
when: PostTranslateRequested(post_id, target_language) when: PostTranslateRequested(post_id, target_language)
-- Gate: airplane mode check let target = post/Post{id: post_id}
-- Opens language picker modal: requires: active_endpoint_configured
-- Available target languages from project blogLanguages -- airplane-mode gating: ai.allium AirplaneModeGating
-- Existing translations shown with status badge (draft/published) requires: target_language in target.project.blog_languages
-- Two sequential AI calls via title model: -- chosen in the language picker modal; existing translations
-- 1. Translate metadata (title, excerpt) to target language -- are listed with their status badge (draft/published)
-- 2. Translate content (full markdown body) to target language let translated_meta = ai_translate_post_metadata(target, target_language)
-- Creates/updates translation record in DB -- AI call 1: title + excerpt via the title model
-- If source post is published: transitions source to draft let translated_content = ai_translate_post_content(target, target_language)
-- (copies file content back to DB so it can be edited) -- 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 { -- Auto-translation on manual save is specified once in
when: PostSaved(post_id) -- action_patterns.allium AutoTranslationChain (triggered by PostSaved),
-- Only an explicit manual save (or publish) triggers this. Auto-saves and -- including the linked-media cascade. It is not duplicated here.
-- 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
}
rule PostPublishAction { rule PostPublishAction {
when: PostPublishRequested(post_id) when: PostPublishRequested(post_id)
-- Implicit save first (awaited) if post is dirty let target = post/Post{id: post_id}
-- Then calls engine publish (see engine_side_effects.allium PublishPostSideEffects) ensures:
-- Also publishes all translations whose source language is published if editor_is_dirty(post_id):
-- UI updates: status badge -> published, sidebar section move 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 { rule PostDiscardChanges {
when: PostDiscardRequested(post_id) when: PostDiscardRequested(post_id)
-- Only available for published posts with pending draft changes let target = post/Post{id: post_id}
-- System confirm dialog: "Discard changes to this post?" requires: target.published_at != null
-- On confirm: reads published version from .md file, requires: target.status = draft
-- restores DB to published state (content=null, status=published) -- only published posts with pending draft changes
-- Editor reloads with restored content 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 { rule PostDeleteAction {
when: PostDeleteRequested(post_id) when: PostDeleteRequested(post_id)
-- System confirm dialog: "Delete this post?" ensures: DeleteConfirmShown(post_id)
-- If published: also deletes .md file and all translation files -- system confirm dialog: "Delete this post?"
-- If never published: only deletes DB record -- (action_patterns.allium confirmation_assignments: post_delete)
-- Removes from DB, closes tab, sidebar removes item }
-- See engine_side_effects.allium DeletePostSideEffects
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 { rule PostInsertLink {
when: PostInsertLinkRequested(post_id) when: PostInsertLinkRequested(post_id)
-- Keyboard shortcut: Ctrl/Cmd+K -- Keyboard shortcut: Ctrl/Cmd+K (markdown mode only, per the
-- Opens InsertPostLinkModal with two tabs: Internal, External -- provides guard)
-- Internal tab: ensures: InsertPostLinkModalOpened(post_id)
-- Search input (debounced, queries post titles) -- two tabs: Internal (debounced title search, similarity
-- Results list: title + status badge (draft/published) -- ranking, Create Post row) and External (URL + display text);
-- If semantic similarity enabled: results ranked by similarity -- picked entries insert [title](/YYYY/MM/DD/slug) or
-- Click inserts markdown link: [title](/YYYY/MM/DD/slug) -- [text](url) at the cursor — modals.allium InsertPostLinkModal
-- "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 { rule PostInsertMedia {
when: PostInsertMediaRequested(post_id) when: PostInsertMediaRequested(post_id)
-- Opens InsertMediaModal (media search variant) ensures: InsertMediaModalOpened(post_id)
-- Search input, grid of media items with bds-thumb:// thumbnails -- media search grid with bds-thumb:// thumbnails; picked items
-- Click inserts markdown: -- insert ![alt](/media/YYYY/MM/file) for images or
-- Images: ![alt](/media/YYYY/MM/file) -- [originalName](/media/YYYY/MM/file) otherwise —
-- Non-images: [originalName](/media/YYYY/MM/file) -- modals.allium InsertMediaModal
} }
rule PostGalleryAction { rule PostGalleryAction {
when: PostGalleryRequested(post_id) when: PostGalleryRequested(post_id)
-- Opens gallery overlay showing all media linked to this post ensures: GalleryOverlayOpened(post_id)
-- Image grid with bds-thumb:// thumbnails -- grid of all media linked to this post; clicking an image
-- Click on image opens lightbox (full-size bds-media:// preview) -- opens the lightbox (full-size preview, arrow navigation,
-- Lightbox: left/right arrow navigation, close button, ESC to close -- ESC to close) — modals.allium GalleryOverlay / LightboxView
} }
rule PostDragDropImage { rule PostDragDropImage {
when: ImageDroppedOnEditor(post_id, file_path) when: ImageDroppedOnEditor(post_id, file_path)
-- Chain of operations (see action_patterns.allium DragDropImageChain): ensures: ImageFileDroppedOnPostEditor(post_id, file_path)
-- 1. Import media file -> media record + file copy + sidecar -- full chain: action_patterns.allium PostDragDropImageChain
-- 2. Generate thumbnails (async: small/medium/large/ai) -- (synchronous import + thumbnails + link + cursor insert,
-- 3. Link media to post (update sidecar linkedPostIds) -- background AI auto-apply and translation cascade)
-- 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.
} }
rule PostLanguageDetect { rule PostLanguageDetect {
when: PostLanguageDetectRequested(post_id) when: PostLanguageDetectRequested(post_id)
-- Gate: airplane mode check let target = post/Post{id: post_id}
-- Sends content sample to title model requires: active_endpoint_configured
-- Auto-sets post language field (no modal) let detected = ai_detect_language(post_suggestion_input(target))
-- Triggers auto-save -- title model over the content sample; no modal
ensures: target.language = detected
ensures: PostAutoSaveTimerReset(post_id)
} }

View File

@@ -70,40 +70,66 @@ surface ScriptEditorSurface {
rule ScriptSave { rule ScriptSave {
when: ScriptSaveRequested(script_id) when: ScriptSaveRequested(script_id)
-- Lua syntax check first using the configured runtime semantics let target = script/Script{id: script_id}
-- If syntax error: blocks save, shows error inline in editor gutter let syntax = ValidateScript(editor_content(script_id))
-- If valid: bumps version, saves to DB + rewrites the published script file -- editor_content: the current code editor buffer
-- Entrypoint list re-discovered from Lua source after save 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 { rule ScriptPublish {
when: ScriptPublishRequested(script_id) when: ScriptPublishRequested(script_id)
-- Only offered while the script is a draft (can_publish gate on the button) let target = script/Script{id: script_id}
-- Validates Lua syntax first (publish gate); invalid source blocks publish requires: target.status = draft
-- Saves current draft fields, then publishes (status -> published) -- can_publish gate on the button
-- Writes the published script file to disk let syntax = ValidateScript(editor_content(script_id))
-- See script.allium PublishScript 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 { rule ScriptCheckSyntax {
when: ScriptCheckSyntaxRequested(script_id) when: ScriptCheckSyntaxRequested(script_id)
-- Validates Lua syntax without saving -- Validation only, never saves
-- Shows errors inline in editor gutter let syntax = ValidateScript(editor_content(script_id))
-- Success shown as toast or status indicator ensures:
if syntax = valid:
ScriptSyntaxOkShown(script_id)
-- toast / status indicator
else:
ScriptSyntaxErrorsShown(script_id, syntax)
} }
rule ScriptRun { rule ScriptRun {
when: ScriptRunRequested(script_id) when: ScriptRunRequested(script_id)
-- Executes the script in the configured Lua runtime let target = script/Script{id: script_id}
-- Calls the configured Lua entrypoint function ensures: ExecuteScriptRequested(target, editor_entrypoint(script_id),
-- stdout/stderr directed to Output panel tab empty_args(), output_panel_sink())
-- Output panel auto-opens if not already visible -- Runs the current buffer's entrypoint in the sandboxed Lua
-- Errors shown in Output panel with line numbers -- 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 { rule ScriptDelete {
when: ScriptDeleteRequested(script_id) when: ScriptDeleteRequested(script_id)
-- No confirmation dialog let target = script/Script{id: script_id}
-- Deletes DB record + published script file on disk -- No confirmation dialog (action_patterns.allium
-- Closes script tab, sidebar removes item -- 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
} }

View File

@@ -40,6 +40,7 @@ surface TemplateEditorSurface {
when editor.can_publish when editor.can_publish
TemplateValidateRequested(editor.template_id) TemplateValidateRequested(editor.template_id)
TemplateDeleteRequested(editor.template_id) TemplateDeleteRequested(editor.template_id)
TemplateForceDeleteConfirmed(editor.template_id)
@guarantee HeaderLayout @guarantee HeaderLayout
-- Header bar with template title tab. -- Header bar with template title tab.
@@ -65,36 +66,75 @@ surface TemplateEditorSurface {
rule TemplateSave { rule TemplateSave {
when: TemplateSaveRequested(template_id) when: TemplateSaveRequested(template_id)
-- Liquid validation first (parse check for syntax errors) let target = template/Template{id: template_id}
-- If invalid: blocks save, shows error inline in editor let validation = ValidateLiquid(editor_content(template_id))
-- If valid: bumps version, saves to DB + rewrites .liquid file -- editor_content: the current code editor buffer
-- See engine_side_effects.allium UpdateTemplateSideEffects 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 { rule TemplatePublish {
when: TemplatePublishRequested(template_id) when: TemplatePublishRequested(template_id)
-- Only offered while the template is a draft (can_publish gate on the button) let target = template/Template{id: template_id}
-- Validates Liquid first (publish gate); invalid source blocks publish requires: target.status = draft
-- Saves current draft fields, then publishes (status -> published) -- can_publish gate on the button
-- Writes the published .liquid file to disk let validation = ValidateLiquid(editor_content(template_id))
-- See template.allium PublishTemplate 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 { rule TemplateValidate {
when: TemplateValidateRequested(template_id) when: TemplateValidateRequested(template_id)
-- Validates Liquid syntax without saving -- Validation only, never saves
-- Shows errors inline in editor let validation = ValidateLiquid(editor_content(template_id))
-- Success shown as toast or status indicator ensures:
if validation = valid:
TemplateValidationOkShown(template_id)
-- toast / status indicator
else:
TemplateValidationErrorsShown(template_id, validation)
} }
rule TemplateDelete { rule TemplateDelete {
when: TemplateDeleteRequested(template_id) when: TemplateDeleteRequested(template_id)
-- Checks for references: posts using this template, tags with postTemplateSlug let target = template/Template{id: template_id}
-- If references exist: system confirm dialog let references = target.referencing_posts.count + target.referencing_tags.count
-- "This template is used by N posts and M tags. Force delete?" ensures:
-- Force delete: nulls templateSlug on referencing posts, if references > 0:
-- nulls postTemplateSlug on referencing tags TemplateForceDeleteConfirmShown(template_id)
-- If no references: deletes without confirmation -- system confirm dialog: "This template is used by N posts
-- Deletes DB record + .liquid file on disk -- and M tags. Force delete?" (action_patterns.allium
-- Closes template tab, sidebar removes item -- 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
} }

View File

@@ -21,34 +21,85 @@ config {
default_language: String = "en" default_language: String = "en"
} }
invariant SplitLocalization { -- ─── Split localization ─────────────────────────────────────
-- Two independent locale scopes: --
-- 1. UI locale: follows OS system locale -- Two independent locale scopes exist per running app. They are
-- 2. Content/render locale: follows project settings (mainLanguage) -- resolved by separate rules from separate inputs and may differ.
-- These are resolved independently 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 { surface LocaleSurface {
-- Input language codes are normalized: facing _: LocaleConsumer
-- Take base language code (split on '-'): "en-US" -> "en"
-- Fall back to "en" if unrecognized provides:
OsLocaleDetected(os_locale)
ProjectMainLanguageResolved(main_language)
MenuTranslationRequested(item_key)
RenderTranslationRequested(label_key)
} }
invariant MenuTranslations { -- ─── Language normalization ─────────────────────────────────
-- Menu item labels are separately translatable --
-- translateMenu() uses UI locale (system/OS locale), NOT render locale -- Both scopes normalize their input the same way: take the base
-- This follows the OS convention: menus match the system language -- 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, -- Template rendering i18n strings (date formats, archive labels,
-- "older posts", "newer posts", etc.) come from locale JSON files -- "older posts", "newer posts", etc.) come from the locale JSON
-- translateRender() and getRenderTranslations() provide these -- 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 { invariant SnowballStemmerCoverage {
-- 24 languages supported for FTS5 search stemming -- ISO 639-1 codes with a dedicated Snowball algorithm
-- ISO 639-1 mapped to Snowball stemmer names -- (stemmer_for_language in bds-core fts.rs); nb/nn/no share the
-- All 5 UI languages are a subset of stemmer languages -- 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
} }

View File

@@ -684,52 +684,79 @@ surface MigrationVersionSurface {
-- ============================================================================ -- ============================================================================
invariant UniqueProjectSlug { 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 { invariant UniquePostSlugPerProject {
-- posts.slug must be unique within each project.project_id
-- Enforced by: posts_project_slug_idx unique index -- 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 { invariant UniqueTranslationPerPostLanguage {
-- post_translations must have unique (translation_for, language)
-- Enforced by: post_translations_translation_language_idx -- 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 { invariant UniqueMediaTranslationPerMediaLanguage {
-- media_translations must have unique (translation_for, language)
-- Enforced by: media_translations_translation_language_idx -- 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 { invariant UniqueTagNamePerProject {
-- tags.name must be unique within each project.project_id
-- Enforced by: tags_project_name_idx unique index -- 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 { invariant UniqueScriptSlugPerProject {
-- scripts.slug must be unique within each project.project_id
-- Enforced by: scripts_project_slug_idx unique index -- 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 { invariant UniqueTemplateSlugPerProject {
-- templates.slug must be unique within each project.project_id
-- Enforced by: templates_project_slug_idx unique index -- 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 { invariant UniquePostMediaLink {
-- post_media must have unique (post_id, media_id) pair
-- Enforced by: post_media_post_media_idx unique index -- 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 { invariant UniqueGeneratedFileHash {
-- generated_file_hashes must have unique (project_id, relative_path)
-- Enforced by: generated_file_hashes_project_path_idx unique index -- 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 { invariant UniqueDismissedDuplicatePair {
-- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b)
-- Enforced by: dismissed_pairs_idx unique index -- 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)
} }
-- ============================================================================ -- ============================================================================

View File

@@ -194,75 +194,81 @@ rule RebuildTemplatesFromFiles {
ensures: not exists template ensures: not exists template
} }
-- Exact Liquid subset required (distilled from bundled starter templates) -- ─── Liquid subset (compatibility contract) ─────────────────
-- No features beyond this list are used. --
-- 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<String> = {
"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<String> = {
"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<String> = {
"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<String> = { "==", ">" }
}
-- 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 { invariant LiquidTagSubset {
-- Only these 5 tags are used: for t in Templates:
-- {% if %} / {% elsif %} / {% else %} / {% endif %} t.status = published implies
-- {% for %} / {% endfor %} for tag in liquid_tags(effective_template_content(t)):
-- {% assign %} tag in config.liquid_allowed_tags
-- {% 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
} }
invariant LiquidFilterSubset { invariant LiquidFilterSubset {
-- Standard filters (4): let allowed = config.liquid_allowed_standard_filters
-- | escape + config.liquid_allowed_custom_filters
-- | url_encode for t in Templates:
-- | default: fallback_value t.status = published implies
-- | append: suffix_string for f in liquid_filters(effective_template_content(t)):
-- f in allowed
-- 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
} }
invariant LiquidOperatorSubset { invariant LiquidOperatorSubset {
-- Comparison: ==, > for t in Templates:
-- Logical: or, and t.status = published implies
-- Truthy/falsy: bare variable in {% if variable %} for op in liquid_comparison_operators(effective_template_content(t)):
-- Special values: blank (nil/empty comparison) op in config.liquid_allowed_comparison_operators
-- 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.
} }
invariant LiquidRenderContext { -- The top-level variables available to templates at render time are
-- Template rendering context provides these top-level variables: -- formalized as the RenderContext value records in template_context.allium.
-- 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
}

View File

@@ -8,11 +8,22 @@ entity TuiState {
focus: sidebar | editor focus: sidebar | editor
selected_index: Integer selected_index: Integer
editing_post: Boolean 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: -- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
-- canonical-language edits update the post, other languages update -- canonical-language edits update the post, other languages update
-- translations; publish routes through the same publishing pipeline. -- 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<String> = [
"project", "editor", "content", "ai",
"technology", "publishing", "data", "mcp"
]
}
surface TuiSurface { surface TuiSurface {
facing _: TuiRuntime facing _: TuiRuntime
@@ -21,207 +32,468 @@ surface TuiSurface {
TuiEventReceived(entity, entity_id, action) TuiEventReceived(entity, entity_id, action)
TuiSettingsChanged(key) TuiSettingsChanged(key)
ShellTaskCompleted(route) 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 { rule SidebarNavigation {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = "up" or code = "down" or code = "j" or code = "k" requires: code = "up" or code = "down" or code = "j" or code = "k"
-- Selection moves over the flattened sidebar items and skips requires: TuiState.focus = sidebar
-- section headers; data comes from BDS.UI.Sidebar.view, identical let items = flattened_sidebar_items()
-- to the GUI sidebar content. -- BDS.UI.Sidebar.view data, identical to the GUI sidebar
ensures: TuiState.selected_index.updated() 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 { rule OpenEntry {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = "enter" requires: code = "enter"
-- Posts open in the editor by default (title + textarea seeded from requires: TuiState.focus = sidebar
-- the persisted form; syntax highlighting and word wrap on; ctrl+e let entry = selected_sidebar_entry(TuiState.selected_index)
-- switches to the rendered preview). New empty posts created with ensures:
-- "n" open in the editor as well. Images open in the terminal image if entry.kind = "post":
-- preview. Other entities report that terminal editing is not TuiState.editing_post = true
-- available yet. TuiState.focus = editor
ensures: TuiState.editing_post.updated() -- 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 { rule SaveAndPublish {
when: TuiKeyPressed(code, modifiers) when: TuiKeyPressed(code, modifiers)
requires: code = "s" or code = "p" requires: code = "s" or code = "p"
requires: modifiers = "ctrl" requires: modifiers = "ctrl"
-- ctrl+s saves the draft, ctrl+p saves and publishes — both through requires: TuiState.editing_post
-- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and ensures:
-- the event bus behave exactly as a GUI save. if code = "s":
ensures: PostPersisted() 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 { rule EditorMode {
when: TuiKeyPressed(code, modifiers) when: TuiKeyPressed(code, modifiers)
requires: code = "e" requires: code = "e"
requires: modifiers = "ctrl" requires: modifiers = "ctrl"
-- ctrl+e toggles between the syntax-highlighted editor (the default requires: TuiState.editing_post
-- mode when opening a post) and the read-only rendered-Markdown ensures: TuiState.editor_preview = not TuiState.editor_preview
-- preview (rendered through tui-markdown so headings/emphasis/lists -- toggles between the syntax-highlighted editor (the default
-- are styled); keys in preview never edit content, and esc returns -- when opening a post) and the read-only rendered-Markdown
-- to the sidebar from either mode. -- preview (tui-markdown: headings/emphasis/lists styled);
ensures: PreviewToggled() -- keys in preview never edit content
} }
rule CodeEditorWrapping { rule EditorEscape {
when: TuiState.editing_post.updated() when: TuiKeyPressed(code, _)
-- The post editor renders the buffer with syntax highlighting requires: code = "esc"
-- (markdown with [[macro]] accents for posts, HTML+Liquid for requires: TuiState.focus = editor
-- templates, Lua for scripts), soft-wraps long lines to the ensures: TuiState.focus = sidebar
-- viewport width, keeps the cursor's visual row visible while -- esc returns to the sidebar from either editor mode
-- scrolling, and highlights the cursor's source line. There is no
-- line-number gutter.
ensures: TuiState.updated()
} }
rule AiQuickAction { rule AiQuickAction {
when: TuiKeyPressed(code, modifiers) when: TuiKeyPressed(code, modifiers)
requires: code = "g" requires: code = "g"
requires: modifiers = "ctrl" requires: modifiers = "ctrl"
-- One-shot AI post analysis (title/excerpt suggestions). Gated by requires: TuiState.editing_post
-- airplane mode: without a local endpoint the TUI shows a status ensures:
-- message instead of calling out. if active_endpoint_configured:
ensures: AiSuggestionsRequestedOrRefused() 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 { rule ProjectSwitcher {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, modifiers)
requires: code = "p" requires: code = "p"
-- "p" opens the projects overlay: all projects from the caching requires: modifiers != "ctrl"
-- database with the active one marked; enter activates the selected requires: TuiState.focus = sidebar
-- project (BDS.Projects.set_active_project) and reloads the sidebar. ensures: ProjectsOverlayOpened(projects: all_projects(), active: active_project())
-- "o" switches to a folder-path prompt with bash-style tab -- all projects from the caching database, active one marked
-- 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 rule ProjectOverlayActivate {
-- offered, dotfolders only for a dot-prefixed input, and a leading when: ProjectsOverlaySelectionConfirmed(project)
-- "~" expands to the home directory. A typed path is validated ensures: ActiveProjectChanged(project)
-- to be an existing directory, then opened as a project -- BDS.Projects.set_active_project
-- (BDS.Projects.create_project with data_path, named after the ensures: SidebarReloaded()
-- 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 rule ProjectOverlayFolderPrompt {
-- paths keep the prompt open and report the error. when: TuiKeyPressed(code, _)
ensures: ProjectSwitchedOrOpened() 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 { rule SidebarSearch {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = "/" requires: code = "/"
-- "/" in sidebar focus opens a vi-style search prompt in the status requires: TuiState.focus = sidebar
-- line that live-filters the current view through the same ensures: SearchPromptOpened()
-- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages, -- vi-style search prompt in the status line
-- 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()
} }
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 { rule CommandPrompt {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = ":" requires: code = ":"
-- Vi-style prompt: ":" opens a filterable list of the parameterless requires: TuiState.focus = sidebar
-- Blog-menu shell commands (metadata diff, validate site, force ensures: CommandListOpened(commands: parameterless_shell_commands())
-- render, rebuilds, reindex, translations, duplicates, upload, -- vi-style prompt: filterable list of the parameterless
-- browser preview URL) and runs the selection through the same -- Blog-menu shell commands (metadata diff, validate site,
-- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?" -- force render, rebuilds, reindex, translations, duplicates,
-- shows the full list as help. Overlays clear the cells beneath -- upload, browser preview URL) — the same
-- them. Commands whose follow-up UI is GUI-only (validate -- BDS.Desktop.ShellCommands backend as the GUI menu. ":?"
-- translations, find duplicates) are marked. Queued tasks report -- shows the full list as help. Commands whose follow-up UI is
-- progress in the status line until all tasks finish. -- GUI-only (validate translations, find duplicates) are marked.
ensures: CommandListShownOrExecuted()
} }
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 { rule SettingsPanel {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = "6" requires: code = "6"
-- "6" opens the settings panel (issue #29), matching the GUI panel requires: TuiState.focus = sidebar
-- numbering: the sidebar lists the same preference sections as the -- "6" matches the GUI panel numbering
-- settings backends (Project, Editor, Content, AI, Technology, ensures: TuiState.view = settings
-- Publishing, Data, MCP). The GUI-only Style tab is intentionally ensures: SettingsSectionsListed(sections: config.tui_settings_sections)
-- absent. Enter on a section opens a section-specific editor in the -- enter on a section opens a section-specific editor in the
-- main area: a generic typed-field form from BDS.UI.SettingsForm. -- 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()
} }
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 { rule TagsPanel {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = "5" requires: code = "5"
-- "5" opens the tags view (issue #34), matching the GUI panel requires: TuiState.focus = sidebar
-- numbering. The sidebar lists the same three sections as the GUI -- "5" matches the GUI panel numbering
-- tags editor (Tag Cloud, Create / Edit, Merge Tags — from ensures: TuiState.view = tags
-- BDS.UI.Sidebar's tags nav view); enter opens the section in the ensures: TagsSectionsListed()
-- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags -- the same three sections as the GUI tags editor (Tag Cloud,
-- operations as the GUI editor. Cloud lists tags with their post -- Create / Edit, Merge Tags — from BDS.UI.Sidebar's tags nav
-- usage counts ordered by usage; manage is alphabetical with "n" -- view); enter opens the section in the main area, all backed
-- (create prompt), enter (rename prompt), "c" (cycle colour through -- by BDS.UI.TagsPanel over the same BDS.Tags operations as the
-- the shared presets), "t" (cycle post template), "d" then "y" -- GUI editor. Cloud lists tags with post usage counts ordered
-- (delete with post-count confirmation; any other key cancels) and -- by usage; manage is alphabetical. Text prompts live in the
-- "s" (sync tags from post tags); merge marks tags with space and -- status line; esc cancels the prompt, then closes the panel.
-- "m" merges the marked tags into the selected one (at least two, -- Domain tag events reload an open panel, keeping it in sync
-- target included). Text prompts live in the status line; esc -- with GUI and CLI writes.
-- 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()
} }
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 { rule GitPanel {
when: TuiKeyPressed(code, _) when: TuiKeyPressed(code, _)
requires: code = "7" requires: code = "7"
-- "7" opens the git panel (issue #30) for content sync: the sidebar requires: TuiState.focus = sidebar
-- lists the changed files from git status (code + path, branch with -- "7" matches the GUI panel numbering; content sync panel
-- ahead/behind counts in the title) with the commit history in its ensures: TuiState.view = git
-- lower half, like the GUI (short hash + subject; ↑ marks commits ensures: GitPanelOpened()
-- that still need a push, ↓ remote-only ones), the main area shows a -- sidebar: changed files from git status (code + path, branch
-- scrollable whole-folder diff (staged + unstaged, capped) paged -- with ahead/behind counts in the title), commit history in
-- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a -- the lower half like the GUI (short hash + subject; ↑ marks
-- status-line commit prompt (git add -A + commit, empty messages -- commits that still need a push, ↓ remote-only ones).
-- rejected), "u" pulls (ff-only) and "s" pushes — both run off the -- Main area: scrollable whole-folder diff (staged + unstaged,
-- UI process and report back as a status toast. Every action is -- capped) paged with pgup/pgdn. Every action is BDS.Git, the
-- BDS.Git, the same backend as the GUI git panel. A project folder -- same backend as the GUI git panel.
-- that is not a git repository shows a message and refuses the
-- actions.
ensures: GitPanelShownOrSynced()
} }
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 { rule ReportPanels {
when: ShellTaskCompleted(route) when: ShellTaskCompleted(route)
requires: route = "metadata_diff" or route = "site_validation" requires: route = "metadata_diff" or route = "site_validation"
-- Completed metadata diff and site validation tasks open a requires: task_completed_in_current_session(route)
-- scrollable report panel showing the differences. Enter applies -- reports completed before this session started never pop up
-- the whole report — metadata diff repairs all items from the ensures: ReportPanelOpened(route)
-- filesystem (file → db) and imports orphan files; site validation -- scrollable report panel showing the differences
-- 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()
} }
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 { rule MultiClientSync {
when: TuiEventReceived(entity, entity_id, action) when: TuiEventReceived(entity, entity_id, action)
-- Domain events refresh the sidebar; a post deleted elsewhere closes -- Keeps TUI sessions synchronized with GUI clients and pipeline
-- the open editor. Keeps TUI sessions synchronized with GUI clients -- ingestion.
-- and pipeline ingestion. ensures: SidebarReloaded()
ensures: TuiState.updated() 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 { rule ServerSideLocale {
when: TuiSettingsChanged(key) when: TuiSettingsChanged(key)
requires: key = "ui.language" requires: key = "ui.language"
-- The TUI re-reads the server-side UI language and re-renders; all
-- clients always share one language.
ensures: TuiRelocalized() ensures: TuiRelocalized()
-- the TUI re-reads the server-side UI language and re-renders;
-- all clients always share one language
} }