From ee961f1b0287744b05101cdac80bdf19762fb903 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 9 Apr 2026 17:46:34 +0200 Subject: [PATCH] chore: and more cleanups --- specs/ai.allium | 37 +++++++++++++++++ specs/git.allium | 18 ++++++++ specs/layout.allium | 38 +++++++++++++++++ specs/mcp.allium | 35 ++++++++++++++++ specs/media.allium | 49 ++++++++++++++++++++++ specs/media_processing.allium | 52 +++++++++++++++++++++++ specs/menu.allium | 16 ++++++++ specs/modals.allium | 70 +++++++++++++++++++++++++++++++ specs/project.allium | 27 ++++++++++++ specs/publishing.allium | 20 +++++++++ specs/script.allium | 18 ++++++++ specs/search.allium | 23 +++++++++++ specs/tag.allium | 26 ++++++++++++ specs/task.allium | 39 +++++++++++++++++- specs/translation.allium | 77 ++++++++++++++++++++++++++++------- 15 files changed, 529 insertions(+), 16 deletions(-) diff --git a/specs/ai.allium b/specs/ai.allium index c44c571..2a8a501 100644 --- a/specs/ai.allium +++ b/specs/ai.allium @@ -19,6 +19,16 @@ entity AiEndpoint { -- airplane: local model (Ollama, LM Studio, etc.) } +surface AiEndpointSurface { + context endpoint: AiEndpoint + + exposes: + endpoint.kind + endpoint.url + endpoint.api_key when endpoint.api_key != null + endpoint.model +} + entity SecureKeyStore { -- Encrypts API keys using OS keychain -- macOS: Keychain, Windows: DPAPI, Linux: libsecret @@ -26,6 +36,10 @@ entity SecureKeyStore { -- No plain-text fallback } +surface SecureKeyStoreSurface { + context _: SecureKeyStore +} + entity ChatConversation { title: String model: String @@ -35,6 +49,17 @@ entity ChatConversation { messages: ChatMessage with conversation = this } +surface ChatConversationSurface { + context conversation: ChatConversation + + exposes: + conversation.title + conversation.model + conversation.created_at + conversation.updated_at + conversation.messages.count +} + entity ChatMessage { conversation: ChatConversation role: system | user | assistant | tool @@ -44,6 +69,18 @@ entity ChatMessage { created_at: Timestamp } +surface ChatMessageSurface { + context message: ChatMessage + + exposes: + message.conversation + message.role + message.content + message.token_usage_input when message.token_usage_input != null + message.token_usage_output when message.token_usage_output != null + message.created_at +} + surface OneShotAiSurface { facing _: AiOperator diff --git a/specs/git.allium b/specs/git.allium index 58a5c72..30ef9dc 100644 --- a/specs/git.allium +++ b/specs/git.allium @@ -17,6 +17,13 @@ value GitSyncStatus { kind: local_only | remote_only | both } +surface GitSyncStatusSurface { + context status: GitSyncStatus + + exposes: + status.kind +} + entity GitRepository { is_initialized: Boolean remote_url: String? @@ -25,6 +32,17 @@ entity GitRepository { has_lfs: Boolean } +surface GitRepositorySurface { + context repo: GitRepository + + exposes: + repo.is_initialized + repo.remote_url when repo.remote_url != null + repo.provider when repo.provider != null + repo.current_branch when repo.current_branch != null + repo.has_lfs +} + surface GitControlSurface { facing _: GitOperator diff --git a/specs/layout.allium b/specs/layout.allium index 8243a52..3c22aa1 100644 --- a/specs/layout.allium +++ b/specs/layout.allium @@ -11,6 +11,24 @@ use "./i18n.allium" as i18n use "./task.allium" as task +surface LayoutControlSurface { + facing _: LayoutOperator + + provides: + ToggleSidebarRequested() + TogglePanelRequested() + ToggleAssistantSidebarRequested() + ActivityClicked(activity_id) +} + +surface LayoutRuntimeSurface { + facing _: LayoutRuntime + + provides: + GitBadgePollTick(badge) + ClearGitBadgeTick(badge) +} + -- ─── Window shell ───────────────────────────────────────────── -- +------------------------------------------------------------+ @@ -37,6 +55,17 @@ value AppShell { status_bar: StatusBar } +surface AppShellSurface { + context shell: AppShell + + exposes: + shell.title_bar.title + shell.sidebar.visible + shell.sidebar.width + shell.content_area.panel.visible + shell.assistant_sidebar.visible +} + value ContentArea { -- tab_bar: see tabs.allium -- editor: routed by active tab; see tabs.allium @@ -69,6 +98,15 @@ value ShellVisibility { assistant_sidebar_visible: Boolean } +surface ShellVisibilitySurface { + context visibility: ShellVisibility + + exposes: + visibility.sidebar_visible + visibility.panel_visible + visibility.assistant_sidebar_visible +} + rule ToggleSidebar { when: ToggleSidebarRequested() ensures: sidebar_visible = not sidebar_visible diff --git a/specs/mcp.allium b/specs/mcp.allium index c2c5caa..2a91932 100644 --- a/specs/mcp.allium +++ b/specs/mcp.allium @@ -15,6 +15,16 @@ entity McpServer { is_running: Boolean } +surface McpServerSurface { + context server: McpServer + + exposes: + server.transport + server.host + server.port + server.is_running +} + entity Proposal { kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata status: pending | accepted | discarded | expired @@ -38,6 +48,24 @@ entity Proposal { } } +surface ProposalSurface { + context proposal: Proposal + + exposes: + proposal.kind + proposal.status + proposal.entity_id + proposal.data + proposal.created_at + proposal.expires_at + proposal.draft_post when proposal.draft_post != null + proposal.proposed_script when proposal.proposed_script != null + proposal.proposed_template when proposal.proposed_template != null + proposal.target_media when proposal.target_media != null + proposal.target_post when proposal.target_post != null + proposal.is_expired +} + config { http_port: Integer = 4124 proposal_ttl_app: Duration = 30.minutes @@ -339,6 +367,13 @@ value McpAgentKind { kind: String } +surface McpAgentKindSurface { + context agent_kind: McpAgentKind + + exposes: + agent_kind.kind +} + rule InstallAgentConfig { when: InstallAgentConfigRequested(agent_kind) -- Writes stdio MCP server config into the agent's config file diff --git a/specs/media.allium b/specs/media.allium index 7415d81..4389732 100644 --- a/specs/media.allium +++ b/specs/media.allium @@ -5,6 +5,17 @@ use "./project.allium" as project +surface MediaControlSurface { + facing _: MediaOperator + + provides: + ImportMediaRequested(project, source_file) + UpdateMediaRequested(media, changes) + DeleteMediaRequested(media) + UpsertMediaTranslationRequested(media, language, title, alt, caption) + RebuildMediaFromFilesRequested(project) +} + value ThumbnailSet { small: String -- 150px width (binary path) medium: String -- 400px width (binary path) @@ -19,6 +30,13 @@ value SidecarFile { path: String } +surface SidecarFileSurface { + context sidecar: SidecarFile + + exposes: + sidecar.path +} + entity Media { project: project/Project filename: String @@ -48,6 +66,37 @@ entity Media { thumbnails: ThumbnailSet } +surface MediaSurface { + context media: Media + + exposes: + media.project + media.filename + media.original_name + media.mime_type + media.size + media.width when media.width != null + media.height when media.height != null + media.title when media.title != null + media.alt when media.alt != null + media.caption when media.caption != null + media.author when media.author != null + media.language when media.language != null + media.file_path + media.sidecar_path + media.checksum when media.checksum != null + media.tags + media.created_at + media.updated_at + media.translations.count + media.linked_posts.count + media.available_languages + media.thumbnails.small + media.thumbnails.medium + media.thumbnails.large + media.thumbnails.ai +} + entity MediaTranslation { media: Media language: String diff --git a/specs/media_processing.allium b/specs/media_processing.allium index 378241c..d380020 100644 --- a/specs/media_processing.allium +++ b/specs/media_processing.allium @@ -10,6 +10,23 @@ use "./media.allium" as media use "./search.allium" as search +surface MediaProcessingControlSurface { + facing _: MediaProcessingOperator + + provides: + ImportMediaRequested(source_path, project) + TagMediaRequested(media, tags) + DeleteMediaRequested(media) + ValidateMediaRequested(project) +} + +surface MediaProcessingRuntimeSurface { + facing _: MediaProcessingRuntime + + provides: + MediaImported(media) +} + -- ============================================================================ -- MEDIA FILE ORGANIZATION -- ============================================================================ @@ -27,6 +44,18 @@ value MediaFileLayout { thumbnail_ai: String -- thumbnails/{prefix}/{id}-ai.jpg } +surface MediaFileLayoutSurface { + context layout: MediaFileLayout + + exposes: + layout.binary_path + layout.sidecar_path + layout.thumbnail_small + layout.thumbnail_medium + layout.thumbnail_large + layout.thumbnail_ai +} + invariant MediaFileNaming { -- Original filename is preserved in original_name field -- Stored filename uses UUID v4: {uuid}.{ext} @@ -109,6 +138,10 @@ value ThumbnailGeneration { -- No _source copy is made. Thumbnails regenerated from the original binary. } +surface ThumbnailGenerationSurface { + context _: ThumbnailGeneration +} + invariant ThumbnailExifHandling { -- EXIF orientation IS applied during thumbnail generation so that -- thumbnails always appear right-side-up regardless of camera metadata. @@ -140,6 +173,14 @@ value ImageProcessing { -- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent) } +surface ImageProcessingSurface { + context processing: ImageProcessing + + exposes: + processing.input_formats + processing.output_formats +} + rule ProcessImageMetadata { when: MediaImported(media) -- Extract image metadata from raw file header @@ -168,6 +209,17 @@ value MediaTranslationFile { caption: String? } +surface MediaTranslationFileSurface { + context file: MediaTranslationFile + + exposes: + file.translation_for + file.language + file.title when file.title != null + file.alt when file.alt != null + file.caption when file.caption != null +} + invariant MediaTranslationFileLayout { for t in MediaTranslations: -- Translation sidecars sit next to the binary, with language suffix diff --git a/specs/menu.allium b/specs/menu.allium index 70c225c..e072955 100644 --- a/specs/menu.allium +++ b/specs/menu.allium @@ -3,6 +3,13 @@ -- Scope: core (read for rendering), extension Bucket F (menu editor UI) -- Distilled from: src/main/engine/MenuEngine.ts +surface MenuManagementSurface { + facing _: MenuOperator + + provides: + UpdateMenuRequested(menu, items) +} + value MenuItem { kind: page | submenu | category_archive | home label: String @@ -18,6 +25,15 @@ entity Menu { home_entry: home_items.first } +surface MenuSurface { + context menu: Menu + + exposes: + menu.items.count + menu.home_items.count + menu.home_entry.label +} + invariant HomeAlwaysPresent { -- The menu always has a Home entry, extracted and prepended for menu in Menus: diff --git a/specs/modals.allium b/specs/modals.allium index 74491ee..91ce39d 100644 --- a/specs/modals.allium +++ b/specs/modals.allium @@ -25,6 +25,13 @@ value AISuggestionsModal { -- Cancel discards all; Apply writes only accepted fields to entity } +surface AISuggestionsModalSurface { + context modal: AISuggestionsModal + + exposes: + modal.fields.count +} + value AISuggestionField { label: String current_value: String @@ -65,6 +72,16 @@ value InsertPostLinkModal { related_posts: List -- similarity-based, shown when query empty } +surface InsertPostLinkModalSurface { + context modal: InsertPostLinkModal + + exposes: + modal.active_tab + modal.search_query + modal.results.count + modal.related_posts.count +} + value InsertLinkResult { post_id: String title: String @@ -87,6 +104,14 @@ value InsertMediaModal { results: List } +surface InsertMediaModalSurface { + context modal: InsertMediaModal + + exposes: + modal.search_query + modal.results.count +} + value InsertMediaResult { media_id: String title: String @@ -108,6 +133,14 @@ value LanguagePickerModal { available_targets: List } +surface LanguagePickerModalSurface { + context modal: LanguagePickerModal + + exposes: + modal.source_language + modal.available_targets.count +} + value LanguageTarget { code: String name: String @@ -130,6 +163,16 @@ value ConfirmDeleteModal { reference_list: List -- titles of referencing entities } +surface ConfirmDeleteModalSurface { + context modal: ConfirmDeleteModal + + exposes: + modal.entity_name + modal.entity_type + modal.reference_count + modal.reference_list +} + -- ─── Confirm Dialog ────────────────────────────────────────── value ConfirmDialog { @@ -141,6 +184,14 @@ value ConfirmDialog { message: String } +surface ConfirmDialogSurface { + context modal: ConfirmDialog + + exposes: + modal.title + modal.message +} + -- Native confirm dialogs (via rfd crate) are NOT modelled as values. -- They are simple yes/no system dialogs with a message string. -- Used by: PostDelete, PostDiscard, TemplateDelete (with references). @@ -157,6 +208,14 @@ value GalleryOverlay { images: List } +surface GalleryOverlaySurface { + context overlay: GalleryOverlay + + exposes: + overlay.post_id + overlay.images.count +} + value GalleryImage { media_id: String thumbnail_url: String -- bds-thumb://media_id @@ -176,6 +235,17 @@ value LightboxView { alt_text: String? } +surface LightboxViewSurface { + context view: LightboxView + + exposes: + view.current_index + view.total_count + view.media_id + view.image_url + view.alt_text when view.alt_text != null +} + -- All modals rendered as centered overlay with backdrop dimming. -- ESC key or backdrop click closes modal (cancel semantics). -- Overlays (PostPicker, ColourPicker) are positioned inline near trigger. diff --git a/specs/project.allium b/specs/project.allium index 00a353f..864027a 100644 --- a/specs/project.allium +++ b/specs/project.allium @@ -3,6 +3,15 @@ -- Scope: core (Wave 1) -- Distilled from: src/main/engine/ProjectEngine.ts, schema.ts +surface ProjectControlSurface { + facing _: ProjectOperator + + provides: + CreateProjectRequested(name, data_path) + SetActiveProjectRequested(project) + DeleteProjectRequested(project) +} + entity Project { name: String slug: String @@ -25,6 +34,24 @@ entity Project { -- Custom data path overrides default } +surface ProjectSurface { + context project: Project + + exposes: + project.name + project.slug + project.description when project.description != null + project.data_path when project.data_path != null + project.is_active + project.created_at + project.updated_at + project.posts.count + project.media.count + project.tags.count + project.internal_base_dir + project.effective_data_dir +} + invariant SingleActiveProject { -- Exactly one project is active at any time let active = Projects where is_active diff --git a/specs/publishing.allium b/specs/publishing.allium index ddefc83..8335c8c 100644 --- a/specs/publishing.allium +++ b/specs/publishing.allium @@ -25,6 +25,26 @@ value UploadTarget { remote_dir: String } +surface PublishJobSurface { + context job: PublishJob + + exposes: + job.ssh_host + job.ssh_user + job.ssh_remote_path + job.ssh_mode + job.status +} + +surface UploadTargetSurface { + context target: UploadTarget + + exposes: + target.kind + target.local_dir + target.remote_dir +} + surface PublishingControlSurface { facing _: PublishOperator diff --git a/specs/script.allium b/specs/script.allium index 842ac0e..078d379 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -26,6 +26,24 @@ entity Script { } } +surface ScriptSurface { + context script: Script + + exposes: + script.slug + script.title + script.kind + script.entrypoint + script.enabled + script.status + script.content when script.content != null + script.version + script.file_path + script.created_at + script.updated_at + script.content_location +} + surface ScriptManagementSurface { facing _: ScriptOperator diff --git a/specs/search.allium b/specs/search.allium index 35d89d5..88ba3b9 100644 --- a/specs/search.allium +++ b/specs/search.allium @@ -7,6 +7,22 @@ use "./post.allium" as post use "./media.allium" as media +surface SearchControlSurface { + facing _: SearchOperator + + provides: + SearchPostsRequested(query, filters) + SearchMediaRequested(query) +} + +surface SearchIndexRuntimeSurface { + facing _: SearchRuntime + + provides: + SearchIndexUpdated(post) + SearchIndexUpdated(media) +} + value StemmerLanguage { -- Snowball stemmers for 24 languages -- ISO 639-1 to Snowball mapping @@ -14,6 +30,13 @@ value StemmerLanguage { code: String } +surface StemmerLanguageSurface { + context language: StemmerLanguage + + exposes: + language.code +} + entity PostSearchIndex { -- SQLite FTS5 virtual table -- Indexed fields: title, excerpt, content, tags, categories diff --git a/specs/tag.allium b/specs/tag.allium index f391bb9..0d54ff0 100644 --- a/specs/tag.allium +++ b/specs/tag.allium @@ -6,6 +6,18 @@ use "./project.allium" as project use "./post.allium" as post +surface TagControlSurface { + facing _: TagOperator + + provides: + CreateTagRequested(project, name, color) + UpdateTagRequested(tag, changes) + DeleteTagRequested(tag) + RenameTagRequested(tag, new_name) + MergeTagsRequested(sources, target) + SyncTagsFromPostsRequested(project) +} + entity Tag { project: project/Project name: String @@ -19,6 +31,20 @@ entity Tag { post_count: posts.count } +surface TagSurface { + context tag: Tag + + exposes: + tag.project + tag.name + tag.color when tag.color != null + tag.post_template_slug when tag.post_template_slug != null + tag.created_at + tag.updated_at + tag.posts.count + tag.post_count +} + invariant UniqueTagNamePerProject { -- Case-insensitive uniqueness for a in Tags: diff --git a/specs/task.allium b/specs/task.allium index 24e29a1..657b902 100644 --- a/specs/task.allium +++ b/specs/task.allium @@ -20,6 +20,37 @@ entity Task { } } +surface TaskControlSurface { + facing _: TaskOperator + + provides: + SubmitTaskRequested(name, work) + CancelTaskRequested(task) + RegisterExternalTaskRequested(name) +} + +surface TaskRuntimeSurface { + facing _: TaskRuntime + + provides: + TaskWorkCompleted(task) + TaskWorkFailed(task, error_message) + ProgressReported(task, value, message) +} + +surface TaskSurface { + context task: Task + + exposes: + task.name + task.status + task.progress when task.progress != null + task.message when task.message != null + task.group_id when task.group_id != null + task.group_name when task.group_name != null + task.created_at +} + config { max_concurrent: Integer = 3 progress_throttle: Duration = 250.milliseconds @@ -39,9 +70,11 @@ invariant FifoQueue { rule SubmitTask { when: SubmitTaskRequested(name, work) let running_tasks = Tasks where status = running - ensures: Task.created(name: name, status: pending) ensures: + let task = Task.created(name: name, status: pending) + task.status = pending if running_tasks.count < config.max_concurrent: + task.status = running TaskStarted(task, work) } @@ -82,7 +115,9 @@ invariant ProgressThrottled { -- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts) rule RegisterExternalTask { when: RegisterExternalTaskRequested(name) - ensures: Task.created(name: name, status: running) + ensures: + let task = Task.created(name: name, status: running) + task.status = running @guidance -- External tasks are not managed by the queue -- The caller is responsible for updating status diff --git a/specs/translation.allium b/specs/translation.allium index c4f4095..c61273b 100644 --- a/specs/translation.allium +++ b/specs/translation.allium @@ -7,11 +7,35 @@ use "./post.allium" as post use "./media.allium" as media +surface TranslationControlSurface { + facing _: TranslationOperator + + provides: + UpsertPostTranslationRequested(post, language, title, content, excerpt) + DeletePostTranslationRequested(translation) + ValidateTranslationsRequested(project) +} + +surface TranslationRuntimeSurface { + facing _: TranslationRuntime + + provides: + PostPublished(post) + PublishTranslationRequested(translation) +} + value SupportedLanguage { -- en, de, fr, it, es code: String } +surface SupportedLanguageSurface { + context language: SupportedLanguage + + exposes: + language.code +} + entity PostTranslation { canonical_post: post/Post language: SupportedLanguage @@ -34,6 +58,24 @@ entity PostTranslation { } } +surface PostTranslationSurface { + context translation: PostTranslation + + exposes: + translation.canonical_post + translation.language.code + translation.title + translation.excerpt when translation.excerpt != null + translation.content when translation.content != null + translation.status + translation.file_path + translation.checksum when translation.checksum != null + translation.created_at + translation.updated_at + translation.published_at when translation.published_at != null + translation.content_location +} + invariant UniqueTranslationPerLanguage { for a in PostTranslations: for b in PostTranslations: @@ -54,15 +96,17 @@ invariant TranslationFilePath { rule UpsertPostTranslation { when: UpsertPostTranslationRequested(post, language, title, content, excerpt) requires: not post.do_not_translate - ensures: PostTranslation.created( - canonical_post: post, - language: language, - title: title, - content: content, - excerpt: excerpt, - status: draft, - file_path: "" - ) + ensures: + let translation = PostTranslation.created( + canonical_post: post, + language: language, + title: title, + content: content, + excerpt: excerpt, + status: draft, + file_path: "" + ) + translation.status = draft -- If translation already exists, update it instead } @@ -70,11 +114,16 @@ rule PublishPostTranslation { when: PostPublished(post) -- All translations are also published when the canonical post is published for t in post.translations: - ensures: t.status = published - ensures: t.published_at = t.published_at ?? now - ensures: TranslationFileWritten(t) - ensures: t.content = null - -- Content moves to filesystem + ensures: PublishTranslationRequested(t) +} + +rule PublishTranslation { + when: PublishTranslationRequested(translation) + ensures: translation.status = published + ensures: translation.published_at = translation.published_at ?? now + ensures: TranslationFileWritten(translation) + ensures: translation.content = null + -- Content moves to filesystem } rule DeletePostTranslation {