initial commit

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-23 10:42:27 +02:00
commit cd998f24a9
57 changed files with 9751 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
-- allium: 1
-- bDS Action Patterns and Chains
-- Scope: cross-cutting (all waves)
-- Distilled from: PostEditor.tsx, MediaEditor.tsx, appStore.ts
-- Cross-cutting patterns for AI operations, auto-translation,
-- drag-and-drop chains, and confirmation dialogs.
use "./post.allium" as post
use "./media.allium" as media
-- ─── External surfaces ──────────────────────────────────────
surface UserAction {
provides: AISuggestionRequested(entity_type, entity_id)
provides: PostSaved(post_id)
provides: PostAutoTranslateCompleted(post_id, language)
}
-- ─── AI operation gating ───────────────────────────────
invariant AIOperationGating {
-- All AI operations route through the active endpoint for the current mode.
-- See ai.allium AirplaneModeGating for endpoint selection logic.
-- If airplane_mode: use airplane endpoint (local model, e.g. Ollama/LM Studio)
-- If online: use online endpoint (cloud provider)
-- If active endpoint not configured: show toast, abort operation
-- Two model categories:
-- Title model: text analysis (suggestions, translation, language detect)
-- Image model: vision-capable (image analysis only)
-- Model selection: per-operation default from settings,
-- overrideable per-conversation in chat panel only
}
-- ─── AI suggestions pattern ────────────────────────────
-- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis.
-- See modals.allium AISuggestionsModal value type.
rule AISuggestionFlow {
when: AISuggestionRequested(entity_type, entity_id)
-- 1. Check AIOperationGating (abort if offline and no local model)
-- 2. Send request to appropriate model:
-- Posts: title model, input = title + excerpt + first 2000 chars
-- Media: image model, input = 448x448 AI thumbnail JPEG
-- 3. Parse response into per-field suggestions
-- 4. Open AISuggestionsModal:
-- Each field: label, current value, suggested value, accept checkbox
-- Accept checkboxes default to true
-- Special case: post slug locked (no checkbox) if ever published
-- 5. On Confirm: apply only accepted fields to entity
-- Posts: triggers auto-save (3s timer reset)
-- Media: triggers explicit save
-- 6. On Cancel: discard all suggestions, no changes
}
-- ─── Auto-translation chain ────────────────────────────
rule AutoTranslationChain {
when: PostSaved(post_id)
-- Gate: AIOperationGating + post.doNotTranslate must be false
-- Triggered after any post save (auto-save, manual Ctrl+S, or unmount)
-- For each configured blogLanguage missing a translation for this post:
-- 1. Enqueue background task: translate metadata (title, excerpt)
-- via title model
-- 2. Enqueue background task: translate content (full markdown)
-- via title model
-- 3. Create/update translation record in DB
-- Tasks: sequential per language, parallel across languages
-- Progress visible in Tasks panel
}
rule MediaMetadataTranslationCascade {
when: PostAutoTranslateCompleted(post_id, language)
-- After a post translation completes for a given language:
-- For each media item linked to this post:
-- If media has source language set
-- and no translation exists for {language}:
-- Enqueue background task: translate media metadata
-- (title, alt, caption) via title model
-- Creates translated sidecar file: {path}.{lang}.meta
}
-- ─── Drag-and-drop image chain ─────────────────────────
-- Full chain when image file is dropped on post editor body.
-- See editor_post.allium PostDragDropImage for trigger rule.
-- Synchronous steps (user waits):
-- 1. importMedia(file) -> new media record + file copy + base sidecar
-- 2. generateThumbnails(media) -> async start (small/medium/large/ai)
-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
-- 4. insertMarkdownImage(cursor) -> insert ![](bds-media://id) at cursor
-- Background steps (non-blocking, results auto-applied):
-- 5. If AI available: aiImageAnalysis(media)
-- Uses image model on 448x448 AI thumbnail
-- Results auto-applied to media metadata (NO modal for drag-drop,
-- unlike manual Quick Action which shows AISuggestionsModal)
-- Triggers sidecar rewrite
-- 6. If auto-translate enabled (post.doNotTranslate=false):
-- For each blogLanguage: translateMediaMetadata(media, lang)
-- Creates translated sidecar files
-- ─── Confirmation dialog patterns ──────────────────────
-- Four distinct patterns used across the application:
-- Pattern 1: System confirm dialog
-- Simple yes/no system dialog, no custom UI
-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist)
-- Pattern 2: ConfirmDeleteModal (custom modal with reference info)
-- Shows entity name, reference counts, linked entity list
-- Two buttons: Cancel, Delete (destructive red style)
-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count)
-- Pattern 3: ConfirmDialog (custom modal for non-delete confirmations)
-- Shows description of action and consequences
-- Two buttons: Cancel, Confirm
-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.")
-- Pattern 4: No confirmation (immediate execution)
-- Action executes on click, no dialog
-- Used by: all Rebuild operations, ScriptDelete, MenuItemDelete,
-- MetadataDiff per-field sync, ImportExecute, MediaTranslationDelete,
-- MediaUnlink

217
specs/ai.allium Normal file
View File

@@ -0,0 +1,217 @@
-- allium: 1
-- bDS AI Integration
-- Scope: core (one-shot operations), extension Bucket C (chat + streaming)
-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts,
-- ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts
-- The rewrite models AI access as two configurable OpenAI-compatible
-- endpoints (online + airplane mode) instead of a fixed named-provider set.
use "./post.allium" as post
use "./media.allium" as media
entity AiEndpoint {
kind: online | airplane
url: String
api_key: String? -- encrypted via SecureKeyStore; null for local models
model: String
-- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.)
-- 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 the host operating system's secure storage.
-- Stored in application settings in encrypted form.
-- No plain-text fallback
}
surface SecureKeyStoreSurface {
context _: SecureKeyStore
}
entity ChatConversation {
title: String
model: String
created_at: Timestamp
updated_at: Timestamp
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
content: String
token_usage_input: Integer?
token_usage_output: Integer?
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
provides:
AnalyzeTaxonomyRequested(post)
AnalyzeImageRequested(media)
AnalyzePostRequested(post)
DetectLanguageRequested(text)
TranslatePostRequested(post, target_language)
TranslateMediaRequested(media, target_language)
}
surface AiChatSurface {
facing _: ChatOperator
provides:
StartChatRequested(model)
SendChatMessageRequested(conversation, content)
RefreshModelCatalogRequested(endpoint)
}
-- One-shot AI tasks (core scope, no streaming)
-- All use OpenAI Chat Completions wire format.
-- Endpoint routing: see AirplaneModeGating invariant below.
-- When no endpoint configured for current mode: disable AI, show toast.
rule AnalyzeTaxonomy {
when: AnalyzeTaxonomyRequested(post)
requires: active_endpoint_configured
-- Suggests tags and categories for a post
ensures: TaxonomySuggestion(tags, categories)
}
rule AnalyzeImage {
when: AnalyzeImageRequested(media)
requires: active_endpoint_configured
requires: is_image(media.mime_type)
-- Vision model generates alt text and caption
ensures: ImageAnalysisResult(alt, caption)
}
rule AnalyzePost {
when: AnalyzePostRequested(post)
requires: active_endpoint_configured
-- Generates title, excerpt, slug suggestions
ensures: PostAnalysisResult(title, excerpt, slug)
}
rule DetectLanguage {
when: DetectLanguageRequested(text)
requires: active_endpoint_configured
ensures: LanguageDetectionResult(language_code)
}
rule TranslatePost {
when: TranslatePostRequested(post, target_language)
requires: active_endpoint_configured
-- Translates title, excerpt, content to target language
ensures: TranslationResult(title, excerpt, content)
}
rule TranslateMedia {
when: TranslateMediaRequested(media, target_language)
requires: active_endpoint_configured
-- Translates title, alt, caption to target language
ensures: MediaTranslationResult(title, alt, caption)
}
-- Chat (extension Bucket C scope, with streaming and tool use)
rule StartChat {
when: StartChatRequested(model)
ensures: ChatConversation.created(
title: generated_chat_title(model),
model: model,
created_at: now,
updated_at: now
)
}
rule SendChatMessage {
when: SendChatMessageRequested(conversation, content)
requires: active_endpoint_configured
ensures: ChatMessage.created(
conversation: conversation,
role: user,
content: content,
token_usage_input: null,
token_usage_output: null,
created_at: now
)
ensures: conversation.updated_at = now
ensures: AiStreamingResponse(conversation)
-- Streaming response with bounded tool-call loop.
-- Blog data tools for post/media querying during chat.
-- Token usage tracking (input, output, cache read/write).
}
-- Model catalog
rule RefreshModelCatalog {
when: RefreshModelCatalogRequested(endpoint)
-- Queries the endpoint's model list API
-- 5-minute cache TTL
ensures: ModelCatalogUpdated(endpoint)
}
invariant AirplaneModeGating {
-- Endpoint routing based on airplane (offline) mode:
-- airplane_mode = true -> use airplane endpoint (local model)
-- airplane_mode = false -> use online endpoint (cloud provider)
-- active_endpoint_configured = true iff the endpoint for the
-- current mode has a non-empty url (and api_key for online).
-- When active endpoint is not configured: AI is unavailable,
-- show toast "AI unavailable — configure {online|airplane} endpoint in Settings"
}
invariant TwoEndpointModel {
-- Two configurable OpenAI-compatible endpoints:
-- online: for cloud providers (requires API key)
-- airplane: for local models (no API key required)
-- Both use the OpenAI Chat Completions wire format.
-- Endpoint selection is configurable rather than tied to hard-coded providers.
}
invariant AiSpecPartitioning {
-- This file covers two distinct but related AI contracts:
-- 1. Core one-shot operations (taxonomy, vision, translation, language detection)
-- 2. Extension chat/model-catalog behaviour
-- Both share the same endpoint routing and airplane-mode gating rules.
}
invariant SecureKeyStorage {
-- API keys are never stored in plain text
-- Always encrypted via host secure storage before persistence
}

84
specs/bds.allium Normal file
View File

@@ -0,0 +1,84 @@
-- allium: 1
-- bDS (Blogging Desktop Server) — Axiom Specification
-- Distilled from the existing bDS application at ../bDS/
-- This is the behavioural baseline for the rewrite effort.
-- An offline-first desktop application for blog authoring with
-- static site generation, SSH publishing, AI integration, and
-- external tool integration via MCP.
-- Core domain
use "./project.allium" as project -- Multi-project management
use "./post.allium" as post -- Post lifecycle, frontmatter, file layout
use "./media.allium" as media -- Media import, thumbnails, sidecars
use "./translation.allium" as translation -- Post and media translations
use "./tag.allium" as tag -- Tags with mass operations
use "./template.allium" as template -- Liquid template management
use "./script.allium" as script -- Scripting (macros, utilities, transforms)
use "./menu.allium" as menu -- OPML navigation menu
use "./metadata.allium" as metadata -- Project config, categories, publishing prefs
-- Infrastructure
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
use "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
use "./preview.allium" as preview -- Local HTTP preview server
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)
use "./task.allium" as task -- Background task manager
use "./i18n.allium" as i18n -- Split localization (UI vs content)
-- UI
use "./layout.allium" as layout -- App shell, activity bar, status bar, panels
use "./tabs.allium" as tabs -- Tab system, editor routing, preview/pin
use "./sidebar_views.allium" as sidebar -- 10 sidebar views, content, behaviour
use "./modals.allium" as modals -- Shared modals (AI suggestions, confirm, gallery)
use "./editor_post.allium" as editor_post -- Post editor view and actions
use "./editor_media.allium" as editor_media -- Media editor view and actions
use "./editor_settings.allium" as editor_settings -- Settings + style views
use "./editor_tags.allium" as editor_tags -- Tags view and colour picker
use "./editor_chat.allium" as editor_chat -- Chat panel and model selector
use "./editor_script.allium" as editor_script -- Script editor
use "./editor_template.allium" as editor_template -- Template editor
use "./editor_misc.allium" as editor_misc -- Dashboard, menu, metadata diff, git diff, etc.
-- Flows and side-effects
use "./ui_data_flow.allium" as ui_data_flow -- Sidebar/editor/tab reactive coordination
use "./engine_side_effects.allium" as engine_side_effects -- CRUD side-effect chains
use "./action_patterns.allium" as action_patterns -- AI gating, translation chains, confirmations
-- Integration
use "./git.allium" as git -- Git operations, LFS, reconciliation
use "./mcp.allium" as mcp -- MCP server (tools, resources, proposals)
use "./ai.allium" as ai -- AI one-shot tasks and chat
use "./embedding.allium" as embedding -- Semantic similarity (HNSW vectors)
use "./cli_sync.allium" as cli_sync -- CLI-to-app notification sync
use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuild
-- Compatibility contract
--
-- MUST stay identical:
-- persistence semantics, post markdown frontmatter,
-- translation file naming, media sidecars, thumbnail conventions,
-- template file formats, menu OPML, generated routes/feeds/sitemaps,
-- full-text search behaviour, slug generation, metadata diff,
-- rebuild-from-filesystem
--
-- MAY change intentionally:
-- implementation language, desktop container, UI framework,
-- editor implementation, internal process model, runtime libraries
-- Resolved questions:
--
-- 1. Slug generation scope: only German and English letters are used.
-- Verify transliteration preserves the established bDS behaviour for
-- ä/ö/ü/ß/ÄÖÜ.
--
-- 2. Liquid subset: see template.allium for the exact subset.
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
-- .size is property access on arrays, NOT a pipe filter.
--
-- 3. Macro calling convention: [[macroslug param1=value1 ...]]
-- Double-bracket syntax, not Liquid tags. Identical to current app.
--
-- 4. AI provider model: the rewrite uses two configurable OpenAI-compatible
-- endpoints (online + airplane mode) rather than a fixed named-provider set.
-- See ai.allium for details.

68
specs/cli_sync.allium Normal file
View File

@@ -0,0 +1,68 @@
-- allium: 1
-- bDS CLI / App Notification Sync
-- Scope: extension (Bucket G — MCP + Automation)
-- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts
entity DbNotification {
entity_type: String -- post, media, script, template
entity_id: String
action: created | updated | deleted
from_cli: Boolean
seen_at: Timestamp?
created_at: Timestamp
-- Derived
is_processed: seen_at != null
}
surface CliSyncRuntimeSurface {
facing _: CliSyncRuntime
provides:
CliMutationPerformed(entity_type, entity_id, action)
DbFileChangeDetected()
}
rule CliWriteNotification {
when: CliMutationPerformed(entity_type, entity_id, action)
-- CLI inserts notification row; app watches for it
ensures: DbNotification.created(
entity_type: entity_type,
entity_id: entity_id,
action: action,
from_cli: true,
seen_at: null
)
}
rule AppWatchNotifications {
when: DbFileChangeDetected()
-- Watches the persisted notification store for external mutations
-- Debounced at 100ms
let unseen = DbNotifications where seen_at = null and from_cli = true
for n in unseen:
ensures: n.seen_at = now
ensures: EngineCacheInvalidated(n.entity_type)
ensures: EntityChangedEvent(n.entity_type, n.entity_id, n.action)
-- IPC event to renderer for UI refresh
}
rule PruneProcessedNotifications {
when: n: DbNotification.created_at + 1.hour <= now
requires: n.is_processed
-- Processed rows: prune after 1 hour
ensures: not exists n
}
rule PruneUnprocessedNotifications {
when: n: DbNotification.created_at + 24.hours <= now
requires: not n.is_processed
-- Unprocessed rows: prune after 24 hours
ensures: not exists n
}
invariant AppNoopNotifier {
-- The desktop application uses a no-op notifier for its own writes
-- It already knows about its own mutations
-- Only CLI writes produce notification rows
}

144
specs/editor_chat.allium Normal file
View File

@@ -0,0 +1,144 @@
-- allium: 1
-- bDS Chat Panel
-- Scope: UI content area — AI chat surface
-- Distilled from: ChatPanel.tsx
-- Describes the layout and behaviour of the chat panel.
use "./i18n.allium" as i18n
-- ─── Chat panel ───────────────────────────────────────────────
value ChatPanelView {
conversation_id: String?
needs_api_key: Boolean
title: String -- conversation title or "New Chat"
selected_model_id: String?
messages: List<ChatMessage>
is_streaming: Boolean
input_text: String
}
value ChatMessage {
role: String -- user | assistant | system
content: String -- user: plain text; assistant: GFM markdown
tool_markers: List<ToolMarker>
is_streaming: Boolean -- true while accumulating
}
value ToolMarker {
tool_name: String
args_preview: String -- string args truncated to config.chat_tool_args_max_length
is_complete: Boolean -- checkmark when done, dot when in-progress
}
value ModelSelectorDropdown {
groups: List<ModelProviderGroup>
selected_model_id: String?
}
surface ModelSelectorDropdownSurface {
context dropdown: ModelSelectorDropdown
exposes:
dropdown.selected_model_id when dropdown.selected_model_id != null
for group in dropdown.groups:
group.provider_name
for model in group.models:
model.model_id
model.display_name
model.context_window
model.max_output_tokens
}
value ModelProviderGroup {
provider_name: String -- e.g. "OpenAI", "Ollama", "LM Studio"
models: List<ModelEntry>
}
value ModelEntry {
model_id: String
display_name: String
context_window: Integer
max_output_tokens: Integer
}
config {
chat_tool_args_max_length: Integer = 30
chat_input_max_height: Integer = 200
}
surface ChatPanelSurface {
context panel: ChatPanelView
exposes:
panel.needs_api_key
panel.title
panel.selected_model_id
panel.is_streaming
panel.input_text
for msg in panel.messages:
msg.role
msg.content
msg.is_streaming
for tm in msg.tool_markers:
tm.tool_name
tm.args_preview
tm.is_complete
provides:
ChatSendMessage(panel.conversation_id, panel.input_text)
when panel.input_text != "" and not panel.is_streaming
ChatAbortStreaming(panel.conversation_id)
when panel.is_streaming
ChatSelectModel(panel.conversation_id, model_id)
ChatOpenSettings()
when panel.needs_api_key
@guarantee ApiKeyRequiredScreen
-- Shown when needs_api_key is true.
-- Key icon, title, description text, "Open Settings" button.
-- No chat functionality available until API key is set.
@guarantee HeaderLayout
-- Left: conversation title (or "New Chat"), CSS ellipsis on overflow.
-- Right: model selector button opening dropdown.
@guarantee ModelSelectorDropdown
-- Dropdown groups models by provider (section headers).
-- Each entry: model display name.
-- Expandable details: context window, max output tokens.
-- Selection is per-conversation override, persisted with conversation.
-- Changing model mid-conversation applies to subsequent messages only.
@guarantee WelcomeScreen
-- Shown when no messages and not streaming.
-- Robot icon, title, description, 5 tip bullet points.
@guarantee MessageRendering
-- User messages: plain text.
-- Assistant messages: rendered as GFM Markdown.
-- External images blocked (CSP), shown as links.
-- Tool markers: checkmark (done) or dot (in-progress) icon,
-- tool name, args truncated to config.chat_tool_args_max_length for strings.
-- Streaming: accumulating markdown + tool markers, thinking dots animation.
@guarantee AutoScroll
-- Message area auto-scrolls to bottom on new messages.
@guarantee InputArea
-- Abort/Stop button: square stop icon, visible only during streaming.
-- Auto-growing textarea: max config.chat_input_max_height px.
-- Enter sends message. Shift+Enter inserts newline.
-- Send button: up-arrow icon, disabled when input is empty or streaming.
@guarantee AssistantActionDispatch
-- Assistant tool calls can trigger navigation actions:
-- open_post(id), open_media(id), open_settings(), etc.
-- Actions dispatched through store, same as user clicks.
-- Navigation actions open tabs with pin intent.
@guarantee TokenTracking
-- Token usage tracked per conversation.
-- Displayed in status bar, not in the chat panel itself.
}

234
specs/editor_media.allium Normal file
View File

@@ -0,0 +1,234 @@
-- allium: 1
-- bDS Media Editor View
-- Scope: UI content area — media editing surface
-- Distilled from: MediaEditor.tsx
-- Describes the layout and behaviour of the media editor rendered in
-- the main content area when a media tab is active.
use "./media.allium" as media
use "./i18n.allium" as i18n
-- ─── Media editor ─────────────────────────────────────────────
value MediaEditorView {
media_id: String
is_image: Boolean
file_name: String -- originalName, read-only
mime_type: String -- read-only
file_size: String -- formatted, read-only
dimensions: String? -- "W x H" if width/height exist, read-only
title: String? -- editable text input
alt_text: String? -- editable text input
caption: String? -- editable textarea (3 rows)
tags: String? -- comma-separated text input
author: String? -- editable text input
language: String? -- select from supported languages
translations: List<MediaTranslationItem>
linked_posts: List<LinkedPostItem>
}
value MediaTranslationItem {
language: String
flag_emoji: String
title: String?
alt_text: String?
caption: String?
}
value LinkedPostItem {
post_id: String
title: String
}
value PostPickerOverlay {
search_query: String
results: List<PostPickerResult>
overflow_count: Integer? -- shown as "and N more" if > 0
}
surface PostPickerOverlaySurface {
context overlay: PostPickerOverlay
exposes:
overlay.search_query
for result in overlay.results:
result.post_id
result.title
overlay.overflow_count when overlay.overflow_count != null
}
value PostPickerResult {
post_id: String
title: String
}
config {
media_post_picker_max_results: Integer = 10
}
surface MediaEditorSurface {
context editor: MediaEditorView
exposes:
editor.file_name
editor.mime_type
editor.file_size
editor.dimensions when editor.dimensions != null
editor.is_image
editor.title
editor.alt_text
editor.caption
editor.tags
editor.author
editor.language
for t in editor.translations:
t.language
t.flag_emoji
t.title
for lp in editor.linked_posts:
lp.post_id
lp.title
provides:
MediaAIImageAnalysisRequested(editor.media_id)
when editor.is_image
MediaDetectLanguageRequested(editor.media_id)
MediaTranslateMetadataRequested(editor.media_id, target_language)
MediaReplaceFileRequested(editor.media_id)
MediaSaveRequested(editor.media_id)
MediaDeleteRequested(editor.media_id)
MediaLinkToPostRequested(editor.media_id)
MediaTranslationEditClicked(editor.media_id, language)
MediaTranslationRefreshClicked(editor.media_id, language)
MediaTranslationDeleteClicked(editor.media_id, language)
MediaUnlinkPostRequested(editor.media_id, post_id)
@guarantee HeaderLayout
-- Header bar with media display name.
-- Actions (right side): Quick Actions dropdown, Replace File button,
-- Save button, Delete button (danger style).
@guarantee QuickActionsDropdown
-- Dropdown menu in header with three entries:
-- AI Image Analysis (robot icon) — only shown for image/* MIME types.
-- Detect Language (magnifier icon).
-- Translate Metadata (globe icon).
@guarantee PreviewArea
-- Images: rendered via bds-media:// protocol with cache-busting timestamp.
-- Non-images: SVG file icon placeholder + original filename text.
@guarantee MetadataForm
-- Form fields in order:
-- File Name (disabled input), MIME Type (disabled input),
-- Size + Dimensions row (disabled inputs),
-- Title (text input), Alt Text (text input), Caption (textarea, 3 rows),
-- Tags (comma-separated text input), Author (text input),
-- Language (select from supported languages).
@guarantee TranslationsSection
-- Shown only when language is set.
-- List of existing translations: flag emoji + language name + title.
-- Per-translation actions: click to edit inline, refresh button, delete button.
-- "No translations" message when list is empty.
@guarantee LinkedPostsSection
-- "Link to Post" button opens inline post picker overlay.
-- List of currently linked posts with document icon. Click navigates to post tab.
-- Per-post unlink button (×).
-- "Not linked to any posts" message when list is empty.
@guarantee PostPickerOverlay
-- Inline overlay positioned near "Link to Post" button (not a modal).
-- Search input filtering unlinked posts by title.
-- Up to config.media_post_picker_max_results results displayed.
-- "and N more" text when total exceeds limit.
-- Click result: links media to selected post, closes overlay.
@guarantee NoAutoSave
-- Unlike the post editor, media editor requires explicit Save button.
}
-- ─── Media editor actions ─────────────────────────────────────
rule MediaAIImageAnalysis {
when: MediaAIImageAnalysisRequested(media_id)
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating)
-- Only available for image/* MIME types (button hidden for non-images)
-- Uses image analysis model (vision-capable, not title model)
-- Input: AI-optimized JPEG thumbnail (448x448, generated on import)
-- Response: suggested title, alt text, caption
-- Opens AISuggestionsModal with 3 fields (title, alt, caption)
-- On confirm: applies checked fields, triggers explicit save
}
rule MediaDetectLanguage {
when: MediaDetectLanguageRequested(media_id)
-- Gate: airplane mode check
-- Input: concatenation of title + alt + caption text
-- Response: detected language code
-- Immediately persists to media record (no modal, no confirmation)
-- Triggers sidecar rewrite
}
rule MediaTranslateMetadata {
when: MediaTranslateMetadataRequested(media_id, target_language)
-- Gate: airplane mode check
-- Opens language picker modal (same pattern as post translate)
-- Two-step process:
-- 1. If source language not set: detect it first (auto-persist)
-- 2. Translate title, alt, caption to target language via title model
-- Creates/updates media translation record
-- Writes translated sidecar file: {path}.{lang}.meta
}
rule MediaReplaceFile {
when: MediaReplaceFileRequested(media_id)
-- Opens native file dialog (no MIME type filter)
-- Copies selected file over existing media file path
-- If image: regenerates thumbnails synchronously (awaited)
-- Preview area updates with cache-busting timestamp query param
}
rule MediaDeleteAction {
when: MediaDeleteRequested(media_id)
-- Opens ConfirmDeleteModal (custom modal, not native dialog)
-- Shows: media display name, linked posts count and list
-- Two buttons: Cancel, Delete (destructive red style)
-- On confirm: deletes file, sidecar, thumbnails, all translations,
-- post-media links, FTS index entry
-- Closes media tab, sidebar removes item
-- See engine_side_effects.allium DeleteMediaSideEffects
}
rule MediaLinkToPost {
when: MediaLinkToPostRequested(media_id)
-- Opens inline post picker overlay (not a modal, positioned near button)
-- Search input filtering unlinked posts by title
-- Up to config.media_post_picker_max_results results shown
-- "and N more" text if results exceed limit
-- Click links media to selected post (updates sidecar linkedPostIds)
-- Linked posts list refreshes immediately
}
rule MediaTranslationEdit {
when: MediaTranslationEditClicked(media_id, language)
-- Loads translation fields inline (title, alt, caption) for that language
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
}
rule MediaTranslationRefresh {
when: MediaTranslationRefreshClicked(media_id, language)
-- Gate: airplane mode check
-- Re-translates from source language to target via title model
-- Overwrites existing translation fields
-- Rewrites translated sidecar file
}
rule MediaTranslationDelete {
when: MediaTranslationDeleteClicked(media_id, language)
-- Deletes translation record from DB
-- Deletes translated sidecar file: {path}.{lang}.meta
-- No confirmation dialog
}

786
specs/editor_misc.allium Normal file
View File

@@ -0,0 +1,786 @@
-- allium: 1
-- bDS Miscellaneous Editor Views
-- Scope: UI content area — dashboard, menu editor, metadata diff, git diff,
-- documentation, validation, find duplicates, import analysis
-- Distilled from: Editor.tsx (Dashboard), MenuEditorView.tsx,
-- MetadataDiffPanel.tsx, ImportAnalysisView.tsx
-- Describes the layout and behaviour of smaller editor views that don't
-- warrant their own spec file.
use "./tabs.allium" as tabs
use "./i18n.allium" as i18n
use "./generation.allium" as generation
-- ─── Dashboard (no tab active) ───────────────────────────────
-- Shown as default/welcome view when no entity tab is active.
value Dashboard {
title: String
subtitle: String
stats: DashboardStats
timeline: DashboardTimeline
tag_cloud: DashboardTagCloud
category_cloud: DashboardCategoryCloud
recent_posts: List<DashboardRecentPost>
}
value DashboardStats {
total_posts: Integer
published_count: Integer
draft_count: Integer
archived_count: Integer -- shown only if > 0
media_count: Integer
image_count: Integer
total_media_size: String -- formatted B/KB/MB/GB
tag_count: Integer
category_count: Integer
}
value DashboardTimeline {
months: List<DashboardTimelineMonth>
}
value DashboardTimelineMonth {
label: String -- month abbreviation
year: Integer
count: Integer
}
value DashboardTagCloud {
tags: List<DashboardTag>
overflow_count: Integer? -- "and N more" when > config.dashboard_max_tags
}
value DashboardTag {
name: String
count: Integer
color: String?
}
value DashboardCategoryCloud {
categories: List<DashboardCategory>
}
value DashboardCategory {
name: String
count: Integer
}
value DashboardRecentPost {
post_id: String
title: String -- "Untitled" as fallback
status: String -- draft | published
date: String -- locale-formatted
}
config {
dashboard_max_tags: Integer = 40
dashboard_tag_min_font: Integer = 11
dashboard_tag_max_font: Integer = 22
dashboard_recent_count: Integer = 5
dashboard_timeline_months: Integer = 12
}
surface DashboardSurface {
context dash: Dashboard
exposes:
dash.title
dash.subtitle
dash.stats.total_posts
dash.stats.published_count
dash.stats.draft_count
dash.stats.archived_count when dash.stats.archived_count > 0
dash.stats.media_count
dash.stats.image_count
dash.stats.total_media_size
dash.stats.tag_count
dash.stats.category_count
for m in dash.timeline.months:
m.label
m.year
m.count
for t in dash.tag_cloud.tags:
t.name
t.count
t.color
dash.tag_cloud.overflow_count when dash.tag_cloud.overflow_count != null
for c in dash.category_cloud.categories:
c.name
c.count
for rp in dash.recent_posts:
rp.title
rp.status
rp.date
provides:
DashboardRecentPostClicked(post_id, single)
DashboardRecentPostClicked(post_id, double)
@guarantee StatCards
-- Three stat cards side by side.
-- Posts card: total number, breakdown tags (published/drafts/archived if > 0).
-- Media card: count, images count, total size (formatted bytes).
-- Tags card: count, categories count.
@guarantee TimelineChart
-- Bar chart of posts over last config.dashboard_timeline_months months that have data.
-- Each bar: count label on top, month abbreviation + year below.
-- Bar height proportional to max count.
@guarantee TagCloud
-- Up to config.dashboard_max_tags tags, sorted alphabetically.
-- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px
-- based on post count.
-- Tags with colours get coloured background with contrast text.
-- Hover title shows post count.
-- "and N more" text when overflow_count > 0.
@guarantee CategoryCloud
-- All categories as badge-like tags.
-- Each shows category name + count.
@guarantee RecentPosts
-- Last config.dashboard_recent_count posts by updatedAt descending.
-- Each row: title (or "Untitled"), status badge (draft/published), date.
-- Single-click: preview tab. Double-click: pin tab.
}
-- ─── Menu editor view ────────────────────────────────────────
-- Visual editor for the OPML navigation menu (meta/menu.opml).
-- See menu.allium for data model.
value MenuEditorView {
items: List<MenuTreeItem>
}
value MenuTreeItem {
item_id: String
kind: String -- home | page | category_archive | submenu
label: String
children: List<MenuTreeItem>
is_home: Boolean -- true for the home item (protected)
}
surface MenuEditorSurface {
context menu: MenuEditorView
exposes:
for item in menu.items:
item.kind
item.label
item.children
item.is_home
provides:
MenuItemAdded(kind, data)
MenuSaveRequested()
MenuItemDeleted(item_id)
when not item.is_home
MenuItemMoved(item_id, direction)
when not item.is_home
@guarantee HeaderLayout
-- Title + description text.
@guarantee Toolbar
-- 8 icon buttons with tooltips:
-- Add Entry (+), Save (floppy), Add Category Archive,
-- Move Up, Move Down, Indent, Unindent, Delete.
@guarantee TreeView
-- Drag-and-drop tree with items showing:
-- Drag handle, kind icon (home/page/category-archive/submenu SVGs),
-- title, selected row highlighting.
-- Nested items indented to show hierarchy.
@guarantee InlineEditing
-- When creating page item: inline PageInput with search
-- (filters posts in "page" category).
-- When creating category archive: inline CategoryInput with search/create.
-- Escape cancels, selection confirms.
@guarantee HomeItemProtection
-- Home item cannot be moved, reordered, or deleted.
-- Maximum one home item allowed.
@guarantee DragDrop
-- Drag handle on each item.
-- Auto-expand collapsed submenus on hover (config.menu_drag_expand_delay ms delay).
-- Drop indicators show target position and nesting level.
@guarantee MoveDirections
-- Up/Down: reorder within same nesting level.
-- Indent: nest under previous sibling (becomes child).
-- Unindent: move to parent's level (becomes next sibling of parent).
}
config {
menu_drag_expand_delay: Integer = 450
}
rule MenuAddItem {
when: MenuItemAdded(kind, data)
-- kind = page: opens lazy-loaded page picker (posts with "page" category)
-- kind = category_archive: opens lazy-loaded category picker
-- kind = submenu: creates empty container node for nesting children
-- kind = home: always available, maximum one allowed
ensures: MenuTreeUpdated()
}
rule MenuSave {
when: MenuSaveRequested()
-- Serializes tree to OPML 2.0, writes meta/menu.opml
ensures: MenuFileWritten()
}
rule MenuMoveItem {
when: MenuItemMoved(item_id, direction)
-- direction = up | down | indent | unindent
requires: not is_home_item(item_id)
ensures: MenuTreeUpdated()
}
rule MenuDeleteItem {
when: MenuItemDeleted(item_id)
requires: not is_home_item(item_id)
-- Removes item and all children, no confirmation dialog
ensures: MenuTreeUpdated()
}
-- ─── Metadata diff view ──────────────────────────────────────
-- Shows DB vs filesystem differences for all entity types.
-- See metadata_diff.allium for diff field definitions.
value MetadataDiffView {
is_scanning: Boolean
active_entity_tab: String -- posts | media | scripts | templates
diff_stats: MetadataDiffStats
field_summaries: List<MetadataDiffFieldSummary>
items: List<MetadataDiffItem>
orphan_files: List<MetadataDiffOrphanFile>
}
value MetadataDiffStats {
total_posts: Integer
published_posts: Integer
draft_posts: Integer
media_files: Integer
scripts: Integer
templates: Integer
}
value MetadataDiffFieldSummary {
field_name: String
diff_count: Integer
}
value MetadataDiffItem {
entity_name: String
entity_type: String
file_missing: Boolean -- badge shown when file not found
field_diffs: List<MetadataDiffField>
}
value MetadataDiffField {
field_name: String
db_value: String?
file_value: String?
}
value MetadataDiffOrphanFile {
file_path: String
entity_type: String
}
surface MetadataDiffSurface {
context diff: MetadataDiffView
exposes:
diff.is_scanning
diff.active_entity_tab
diff.diff_stats
for fs in diff.field_summaries:
fs.field_name
fs.diff_count
for item in diff.items:
item.entity_name
item.file_missing
for fd in item.field_diffs:
fd.field_name
fd.db_value
fd.file_value
for orphan in diff.orphan_files:
orphan.file_path
provides:
MetadataDiffScanRequested()
MetadataDiffSyncFieldToFile(entity_name, field_name)
MetadataDiffSyncFieldToDb(entity_name, field_name)
MetadataDiffSyncAllFieldToFile(field_name)
MetadataDiffSyncAllFieldToDb(field_name)
MetadataDiffImportOrphan(file_path)
@guarantee HeaderLayout
-- Title + description text.
-- Stats row: 6 stat items (total posts, published, drafts, media, scripts, templates).
@guarantee ScanAction
-- Scan/Rescan button at top.
-- Progress bar + message during scan.
@guarantee EntityTabs
-- Tabs: Posts, Media, Scripts, Templates — each with badge count of diffs.
@guarantee FieldSummaryPills
-- Clickable filter pills per field, each with count.
-- Two bulk sync buttons per pill: DB→File and File→DB (syncs all items for that field).
@guarantee DiffItemCards
-- Per-item card: header (entity label + file-missing badge),
-- field rows (field name, DB value, file value),
-- two sync buttons per field (DB→File, File→DB).
-- Button disappears after successful sync (field now matches).
@guarantee OrphanFilesSection
-- Files on disk with no matching DB record shown at bottom of each entity tab.
-- "Import" button creates DB record from file metadata.
@guarantee NoConfirmation
-- Individual field syncs require no confirmation dialog.
}
-- ─── Git diff view ────────────────────────────────────────────
-- Renders diff for a file (working tree vs HEAD) or a commit.
-- File diff: id = "git-diff:{filePath}"
-- Commit diff: id = "git-diff:commit:{commitHash}"
value GitDiffView {
diff_id: String
diff_type: String -- file | commit
display_mode: String -- inline | side_by_side (from editor settings)
}
surface GitDiffSurface {
context diff: GitDiffView
exposes:
diff.diff_id
diff.diff_type
diff.display_mode
@guarantee DiffDisplayModes
-- Supports inline and side-by-side diff display modes.
-- Mode comes from editor settings (Diff View Style).
@guarantee ReadOnly
-- No actions beyond viewing — changes are managed via git sidebar.
}
-- ─── Documentation views ─────────────────────────────────────
-- documentation: renders DOCUMENTATION.md as styled HTML
-- api_documentation: renders API.md as styled HTML
surface DocumentationSurface {
@guarantee MarkdownRendering
-- Renders markdown file as styled HTML.
-- No edit actions. Read-only view.
}
-- ─── Site validation view ───────────────────────────────────
value SiteValidationReport {
project_id: String
expected_url_count: Integer
existing_html_count: Integer
missing_url_paths: List<String> -- in sitemap, no HTML on disk
extra_url_paths: List<String> -- HTML on disk, not in sitemap
updated_post_url_paths: List<String> -- source .md newer than HTML
affected_sections: Set<generation/GenerationSection>
}
surface SiteValidationSurface {
context report: SiteValidationReport
exposes:
report.project_id
report.expected_url_count
report.existing_html_count
report.missing_url_paths
report.extra_url_paths
report.updated_post_url_paths
report.affected_sections
provides:
SiteValidationScanRequested()
SiteValidationApplyRequested(report)
when report.missing_url_paths.count > 0
or report.extra_url_paths.count > 0
or report.updated_post_url_paths.count > 0
@guarantee SummaryLine
-- "Expected URLs: N — Existing HTML URLs: N — Missing: N — Extra: N — Updated: N"
@guarantee UrlSections
-- Three sections: Missing URLs, Extra URLs, Updated URLs.
-- Each section: heading + list of URL paths, or "None found".
@guarantee ApplyAction
-- Apply button disabled when nothing to fix.
-- On apply: renders missing, deletes extra, re-renders updated.
-- Toast: "Validation applied: N rendered, N deleted".
}
rule SiteValidationScan {
when: SiteValidationScanRequested()
-- Parses <loc> entries from sitemap.xml into expected URL set
-- Scans HTML output dir for index.html files (zero-byte = missing)
-- Compares source .md mtime against generated HTML mtime
ensures: SiteValidationReport
}
rule SiteValidationApply {
when: SiteValidationApplyRequested(report)
-- Classifies affected paths into generation sections (core, single, category, tag, date)
-- Renders only affected sections in parallel
-- Deletes extra HTML files, removes empty directories
-- Regenerates calendar if anything changed
-- Rebuilds search index if anything rendered or deleted
ensures: ApplyValidationRequested(report.project_id, report.affected_sections)
}
-- ─── Translation validation view ───────────────────────────
value TranslationValidationReport {
checked_database_row_count: Integer
checked_filesystem_file_count: Integer
invalid_database_rows: List<TranslationValidationIssue>
invalid_filesystem_files: List<TranslationValidationIssue>
}
value TranslationValidationIssue {
issue: String
-- Issue kinds:
-- missing_source_post: translationFor points to nonexistent post
-- same_language_as_canonical: translation language matches source post language
-- do_not_translate_has_translations: source post is doNotTranslate but has translations
-- content_in_database: published translation still has content in DB (should be on disk)
translation_id: String?
translation_for: String
canonical_language: String?
translation_language: String
title: String?
file_path: String?
}
surface TranslationValidationSurface {
context report: TranslationValidationReport
exposes:
report.checked_database_row_count
report.checked_filesystem_file_count
for issue in report.invalid_database_rows:
issue.issue
issue.translation_id
issue.translation_for
issue.canonical_language
issue.translation_language
issue.title
issue.file_path
for issue in report.invalid_filesystem_files:
issue.issue
issue.file_path
provides:
TranslationValidationScanRequested()
TranslationValidationFixRequested(report)
when report.invalid_database_rows.count > 0
or report.invalid_filesystem_files.count > 0
@guarantee SummaryLine
-- "Checked DB rows: N — Checked files: N — Invalid DB rows: N — Invalid files: N"
@guarantee IssueSections
-- Two sections: Database Issues, Filesystem Issues.
-- Each issue rendered as card with coloured left border.
-- Card shows: issue label, source post ID, translation ID, title, languages, file path.
@guarantee IssueTypes
-- same_language_as_canonical: translation language matches source.
-- do_not_translate_has_translations: source is doNotTranslate.
-- content_in_database: published translation has content in DB.
-- missing_source_post: translationFor references nonexistent post.
@guarantee FixAction
-- Revalidate button + Fix button (disabled when no issues).
-- Fix: content_in_database -> flush to .md file, set content null.
-- Other issues -> delete DB row or .md file.
-- After fix: automatically re-validates.
-- Toast: "Deleted N DB rows and N files, flushed N translations to disk".
}
rule TranslationValidationScan {
when: TranslationValidationScanRequested()
-- Database pass: checks all translation rows for integrity issues
-- Filesystem pass: scans posts/ for translation .md files, checks frontmatter
ensures: TranslationValidationReport
}
rule TranslationValidationFix {
when: TranslationValidationFixRequested(report)
-- content_in_database: flushes content to .md file, sets content = null in DB
-- missing_source_post | same_language_as_canonical | do_not_translate:
-- DB issues: deletes translation row
-- Filesystem issues: deletes the .md file
-- After fix: automatically re-validates
ensures: TranslationValidationScan
}
-- ─── Find duplicates view ──────────────────────────────────
value DuplicateSearchResult {
pairs: List<DuplicatePair>
has_more: Boolean -- pagination with config.duplicate_page_size per page
}
value DuplicatePair {
post_id_a: String
title_a: String
post_id_b: String
title_b: String
similarity: Decimal -- 0.0 to 1.0
exact_match: Boolean -- true if titles + content identical
}
config {
duplicate_similarity_threshold: Decimal = 0.92
duplicate_page_size: Integer = 500
duplicate_neighbor_count: Integer = 21
}
surface DuplicatesSurface {
context result: DuplicateSearchResult
exposes:
for pair in result.pairs:
pair.title_a
pair.title_b
pair.similarity
pair.exact_match
result.has_more
provides:
DuplicateSearchRequested()
DuplicatePairDismissed(post_id_a, post_id_b)
DuplicatePairsBatchDismissed(pair_ids)
DuplicatePostClicked(post_id)
DuplicateShowMoreRequested()
when result.has_more
@guarantee SemanticSimilarityGate
-- Requires semanticSimilarityEnabled in project metadata.
-- If disabled: shows "Semantic similarity is not enabled" message.
-- No search functionality available when disabled.
@guarantee ActionsBar
-- Refresh button, Check All, Uncheck All,
-- Dismiss Checked (with count), disabled when none checked.
@guarantee PairRows
-- Each row: checkbox, post A title (clickable -> opens tab),
-- arrow, post B title (clickable -> opens tab),
-- similarity badge (percentage or "Exact Match"),
-- Dismiss button.
-- Exact matches styled distinctly from similarity matches.
@guarantee Pagination
-- "Show More" button when has_more is true.
-- config.duplicate_page_size pairs per page.
@guarantee BatchDismiss
-- Batch insert in chunks of 100.
-- Dismissed pairs excluded from future searches.
}
rule DuplicateSearch {
when: DuplicateSearchRequested()
requires: semantic_similarity_enabled
-- Loads USearch vector index for project
-- For each indexed post: search config.duplicate_neighbor_count nearest neighbors
-- similarity = max(0, 1 - distance)
-- Filter: similarity >= config.duplicate_similarity_threshold, exclude dismissed
-- For 100% embedding similarity: load post bodies, compare title+content
-- If identical: exact_match = true
-- Sort: exact matches first, then descending similarity
ensures: DuplicateSearchResult
}
rule DuplicateDismiss {
when: DuplicatePairDismissed(post_id_a, post_id_b)
-- Inserts into dismissed_duplicate_pairs with canonical ID ordering
-- Excluded from future searches
ensures: PairDismissed(post_id_a, post_id_b)
}
rule DuplicateBatchDismiss {
when: DuplicatePairsBatchDismissed(pair_ids)
-- Batch insert in chunks of 100
ensures: for pair in pair_ids: PairDismissed(pair)
}
-- ─── Import analysis view ───────────────────────────────────
-- Editor for WXR (WordPress eXtended RSS) import definitions.
-- Keyed by import definition ID. Opened as always-pinned tab.
value ImportAnalysisView {
definition_id: String
definition_name: String -- editable name input
uploads_folder_path: String? -- path display + Browse button
wxr_file_path: String? -- path display + Select & Analyze button
is_loading: Boolean
report: ImportAnalysisReport?
}
value ImportAnalysisReport {
site_info: ImportSiteInfo
post_stats: ImportEntityStats
page_stats: ImportEntityStats
media_stats: ImportMediaStats
category_stats: ImportTaxonomyStats
tag_stats: ImportTaxonomyStats
date_distribution: List<ImportYearDistribution>
conflicts: List<ImportConflict>
macros: List<ImportMacro>
}
value ImportSiteInfo {
title: String
url: String
language: String
source_file: String
}
value ImportEntityStats {
new_count: Integer
update_count: Integer
conflict_count: Integer
duplicate_count: Integer
}
value ImportMediaStats {
new_count: Integer
update_count: Integer
conflict_count: Integer
duplicate_count: Integer
missing_count: Integer
}
value ImportTaxonomyStats {
existing_count: Integer
mapped_count: Integer
new_count: Integer
}
value ImportYearDistribution {
year: Integer
post_count: Integer
media_count: Integer
}
value ImportConflict {
item_type: String -- post | page | media
item_name: String
resolution: String -- import | skip | merge
}
value ImportMacro {
name: String
usage_count: Integer
parameters: List<String>
validation_status: String -- valid | invalid | unknown
}
surface ImportAnalysisSurface {
context analysis: ImportAnalysisView
exposes:
analysis.definition_name
analysis.uploads_folder_path
analysis.wxr_file_path
analysis.is_loading
analysis.report when analysis.report != null
provides:
ImportAnalyzeRequested(analysis.definition_id, file_path)
when analysis.wxr_file_path != null
ImportExecuteRequested(analysis.definition_id)
when analysis.report != null
ImportConflictResolutionChanged(item_name, resolution)
ImportTaxonomyMappingChanged(source_term, target_term)
ImportAITaxonomyAnalysisRequested(analysis.definition_id)
@guarantee FileSelectors
-- Uploads folder: path display + Browse button (native folder dialog).
-- WXR file: path display + Select & Analyze button (native file dialog).
@guarantee LoadingState
-- Spinner + progress step + detail text during analysis.
@guarantee SiteInfoCard
-- Shows: site title, URL, language, source file path.
@guarantee StatCards
-- Posts (new/update/conflict/duplicate), Pages (same),
-- Media (new/update/conflict/duplicate/missing),
-- Categories (existing/mapped/new), Tags (existing/mapped/new).
@guarantee DateDistribution
-- Year-by-year bar charts for posts + media.
@guarantee ConflictsSection
-- Collapsible. Per-item dropdown: Import/Skip/Merge.
-- Default: Import for new items, Skip for existing matches.
@guarantee TaxonomySection
-- Collapsible. Category + tag pills.
-- Click pill to map to existing term. Inline edit with suggestion dropdown.
-- AI analyze button (with model selector dropdown).
-- Gate: airplane mode check for AI taxonomy analysis.
@guarantee MacrosSection
-- Collapsible. Discovered macros with usage counts, parameters,
-- validation status (valid/invalid/unknown badges).
@guarantee ExecuteAction
-- Execute button shows importable counts (tags, posts, media, pages).
-- Disabled if nothing to import.
-- Progress bar during execution (current/total, phase, detail, ETA).
-- No confirmation dialog — executes immediately.
@guarantee CreatedEntitiesRefresh
-- Created entities appear in sidebar immediately after execution.
}
rule ImportSelectAndAnalyze {
when: ImportAnalyzeRequested(definition_id, file_path)
-- Parses WXR XML file
-- Extracts: posts, pages, media, tags, categories, authors
-- Shows summary counts per entity type
-- Identifies conflicts: duplicate slugs, existing categories/tags
}
rule ImportExecute {
when: ImportExecuteRequested(definition_id)
-- No confirmation dialog — executes immediately
-- Processes items per conflict resolution settings
-- Creates posts, media, tags, categories as needed
-- Summary with counts shown in import view on completion
-- Created entities appear in sidebar immediately (store updated)
}

317
specs/editor_post.allium Normal file
View File

@@ -0,0 +1,317 @@
-- allium: 1
-- bDS Post Editor View
-- Scope: UI content area — post editing surface
-- Distilled from: PostEditor.tsx
-- Describes the layout and behaviour of the post editor rendered in
-- the main content area when a post tab is active.
-- Tab routing is in tabs.allium. Sidebar navigation is in sidebar_views.allium.
use "./tabs.allium" as tabs
use "./post.allium" as post
use "./i18n.allium" as i18n
-- ─── Post editor ──────────────────────────────────────────────
value PostEditorView {
post_id: String
header: PostEditorHeader
metadata: PostEditorMetadata
metadata_expanded: Boolean -- starts expanded when title is empty
excerpt_expanded: Boolean
editor_mode: String -- visual | markdown | preview
footer: PostEditorFooter
}
value PostEditorHeader {
title: String -- post title or "Untitled"
is_dirty: Boolean
status: String -- draft | published | archived
is_auto_saving: Boolean
}
value PostEditorMetadata {
title: String -- editable text input
tags: List<String> -- autocomplete chip input
author: String? -- text input
language: String? -- select from supported languages
do_not_translate: Boolean -- checkbox
slug: String -- read-only text input
categories: List<String> -- chip input
template_slug: String? -- select (shown only when templates exist)
post_links: PostLinksPanel
linked_media: List<LinkedMediaItem>
}
value PostLinksPanel {
backlinks: List<PostLinkReference> -- posts linking to this post
outlinks: List<PostLinkReference> -- posts this post links to
}
value PostLinkReference {
post_id: String
title: String
}
value LinkedMediaItem {
media_id: String
has_thumbnail: Boolean
name: String
sort_order: Integer
}
value PostEditorFooter {
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
published_at: String? -- locale-formatted date, only when post was published
}
value TranslationFlag {
language: String
flag_emoji: String
status: String -- draft | published
is_active: Boolean -- true when this language is currently being edited
}
surface TranslationFlagSurface {
context flag: TranslationFlag
exposes:
flag.language
flag.flag_emoji
flag.status
flag.is_active
}
surface PostEditorSurface {
context editor: PostEditorView
exposes:
editor.header.title
editor.header.is_dirty
editor.header.status
editor.header.is_auto_saving
editor.metadata_expanded
editor.excerpt_expanded
editor.editor_mode
editor.footer.created_at
editor.footer.updated_at
editor.footer.published_at when editor.footer.published_at != null
provides:
PostAIAnalysisRequested(editor.post_id)
PostTranslateRequested(editor.post_id, target_language)
PostSaved(editor.post_id)
PostPublishRequested(editor.post_id)
when editor.header.status = draft
PostDiscardRequested(editor.post_id)
when editor.header.status = draft
PostDeleteRequested(editor.post_id)
PostInsertLinkRequested(editor.post_id)
when editor.editor_mode = markdown
PostInsertMediaRequested(editor.post_id)
when editor.editor_mode = markdown
PostGalleryRequested(editor.post_id)
ImageDroppedOnEditor(editor.post_id, file_path)
PostLanguageDetectRequested(editor.post_id)
@guarantee HeaderLayout
-- Header bar with two areas.
-- Left: title text with dirty indicator dot (●) when is_dirty is true.
-- Right: status badge, auto-save indicator (when saving),
-- Quick Actions dropdown, Publish button (only when draft, success style),
-- Discard button (only when draft), Delete button.
@guarantee QuickActionsDropdown
-- Dropdown menu in header with two entries:
-- AI Analysis (robot icon) — suggests title, excerpt, slug.
-- Translate Post (globe icon) — opens translation modal.
@guarantee MetadataSection
-- Collapsible section. Starts expanded when title is empty.
-- Two-column layout.
-- Left column: Title, Tags, Author, Language + detect button,
-- Do Not Translate checkbox, Slug (read-only), Categories,
-- Template (select, only when templates exist), PostLinks.
-- Right column: LinkedMediaPanel.
@guarantee TagAutocomplete
-- Tag input with autocomplete.
-- Standard: prefix match on existing tag names (case-insensitive).
-- When semanticSimilarityEnabled: also suggests tags from 10 similar posts,
-- weighted by similarity, top 5 shown.
-- Merged results: prefix matches first, then embedding suggestions.
@guarantee TranslationFlagsBar
-- Row of flag emoji buttons inline with metadata toggle.
-- One flag per language: canonical language + each translation.
-- Each flag shows status (draft/published) via CSS class.
-- Active flag highlighted. Click switches editor to that language's draft.
@guarantee ExcerptSection
-- Collapsible section with textarea (4 rows).
@guarantee EditorBodyToolbar
-- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview),
-- action buttons (markdown mode only): Gallery (with media count),
-- Insert Post Link, Insert Media.
@guarantee EditorModes
-- Visual: rich-text WYSIWYG editor.
-- Markdown: code editor with markdown-with-macros language,
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
-- Preview: iframe showing rendered preview.
@guarantee DragDropImages
-- Drop image file onto editor area triggers import chain.
@guarantee FooterLayout
-- Three date stamps: Created, Updated, Published (only when published_at exists).
-- Locale-formatted dates.
@guarantee LinkedMediaPanel
-- Right column of metadata showing media items linked to this post.
-- Actions: Import & Link (native file dialog), Link Existing (media picker),
-- Unlink (no confirmation), Reorder (drag handle), Click (opens media tab).
}
config {
post_auto_save_delay: Integer = 3000
post_content_sample_length: Integer = 2000
}
invariant PostAutoSave {
-- Auto-saves after config.post_auto_save_delay ms of idle in the editor.
-- Also auto-saves on unmount/tab switch.
-- Ctrl/Cmd+S triggers immediate save.
-- Saves: title, content, excerpt, tags, categories, templateSlug, language.
}
invariant PostDirtyTracking {
-- Compares canonical draft + translation drafts against saved state.
-- Dirty indicator shown in header (●) and tab bar.
}
invariant PostEditorModePersistence {
-- Editor mode (visual/markdown/preview) persists per session.
-- Default mode comes from editor settings.
}
-- ─── Post editor actions ────────────────────────────────────
rule PostAIAnalysis {
when: PostAIAnalysisRequested(post_id)
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating)
-- Uses title model (not default chat model)
-- Input: post title + excerpt + content (first config.post_content_sample_length chars)
-- Response: suggested title, excerpt, slug
-- Opens AISuggestionsModal with 3 fields:
-- Each field: current value, suggested value, accept checkbox
-- Slug field locked (no accept checkbox) if post was ever published
-- On confirm: applies only checked fields, triggers auto-save
}
rule PostTranslateAction {
when: PostTranslateRequested(post_id, target_language)
-- Gate: airplane mode check
-- Opens language picker modal:
-- Available target languages from project blogLanguages
-- Existing translations shown with status badge (draft/published)
-- Two sequential AI calls via title model:
-- 1. Translate metadata (title, excerpt) to target language
-- 2. Translate content (full markdown body) to target language
-- Creates/updates translation record in DB
-- If source post is published: transitions source to draft
-- (copies file content back to DB so it can be edited)
}
rule PostAutoTranslateOnSave {
when: PostSaved(post_id)
-- Gate: airplane mode check + auto_translate not disabled (doNotTranslate=false)
-- For each configured blog language missing a translation:
-- Enqueue background translation task (title model)
-- Each task: translate metadata + content, create translation record
-- Cascades to linked media: for each linked media item,
-- translate media metadata for missing languages
-- See action_patterns.allium AutoTranslationChain for full chain
}
rule PostPublishAction {
when: PostPublishRequested(post_id)
-- Implicit save first (awaited) if post is dirty
-- Then calls engine publish (see engine_side_effects.allium PublishPostSideEffects)
-- Also publishes all translations whose source language is published
-- UI updates: status badge -> published, sidebar section move
}
rule PostDiscardChanges {
when: PostDiscardRequested(post_id)
-- Only available for published posts with pending draft changes
-- System confirm dialog: "Discard changes to this post?"
-- On confirm: reads published version from .md file,
-- restores DB to published state (content=null, status=published)
-- Editor reloads with restored content
}
rule PostDeleteAction {
when: PostDeleteRequested(post_id)
-- System confirm dialog: "Delete this post?"
-- If published: also deletes .md file and all translation files
-- If never published: only deletes DB record
-- Removes from DB, closes tab, sidebar removes item
-- See engine_side_effects.allium DeletePostSideEffects
}
rule PostInsertLink {
when: PostInsertLinkRequested(post_id)
-- Keyboard shortcut: Ctrl/Cmd+K
-- Opens InsertPostLinkModal with two tabs: Internal, External
-- Internal tab:
-- Search input (debounced, queries post titles)
-- Results list: title + status badge (draft/published)
-- If semantic similarity enabled: results ranked by similarity
-- Click inserts markdown link: [title](/YYYY/MM/DD/slug)
-- "Create Post" option at bottom of search results:
-- Creates new post with search query as title
-- Inserts link to newly created post
-- External tab:
-- URL input + optional display text input
-- Inserts: [text](url) or bare url if no display text
}
rule PostInsertMedia {
when: PostInsertMediaRequested(post_id)
-- Opens InsertMediaModal (media search variant)
-- Search input, grid of media items with bds-thumb:// thumbnails
-- Click inserts markdown:
-- Images: ![alt](bds-media://id)
-- Non-images: [originalName](bds-media://id)
}
rule PostGalleryAction {
when: PostGalleryRequested(post_id)
-- Opens gallery overlay showing all media linked to this post
-- Image grid with bds-thumb:// thumbnails
-- Click on image opens lightbox (full-size bds-media:// preview)
-- Lightbox: left/right arrow navigation, close button, ESC to close
}
rule PostDragDropImage {
when: ImageDroppedOnEditor(post_id, file_path)
-- Chain of operations (see action_patterns.allium DragDropImageChain):
-- 1. Import media file -> media record + file copy + sidecar
-- 2. Generate thumbnails (async: small/medium/large/ai)
-- 3. Link media to post (update sidecar linkedPostIds)
-- 4. Insert markdown image at cursor: ![](bds-media://id)
-- 5. If AI available: AI image analysis (async, auto-applied, no modal)
-- 6. If auto-translate enabled: cascade translate media metadata
-- Steps 1-4 synchronous. Steps 5-6 background tasks.
}
rule PostLanguageDetect {
when: PostLanguageDetectRequested(post_id)
-- Gate: airplane mode check
-- Sends content sample to title model
-- Auto-sets post language field (no modal)
-- Triggers auto-save
}

View File

@@ -0,0 +1,96 @@
-- allium: 1
-- bDS Script Editor View
-- Scope: UI content area — script editing surface
-- Distilled from: ScriptEditor.tsx
-- Describes the layout and behaviour of the script editor.
-- See script.allium for entity model.
use "./script.allium" as script
use "./i18n.allium" as i18n
-- ─── Script editor view ──────────────────────────────────────
value ScriptEditorView {
script_id: String
title: String -- editable text input
slug: String -- editable text input, auto-generated from title
kind: String -- select: utility | macro | transform
entrypoint: String -- select: dynamically discovered functions, "main" always first
enabled: Boolean -- checkbox
content: String -- code editor content
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
surface ScriptEditorSurface {
context editor: ScriptEditorView
exposes:
editor.title
editor.slug
editor.kind
editor.entrypoint
editor.enabled
editor.created_at
editor.updated_at
provides:
ScriptSaveRequested(editor.script_id)
ScriptRunRequested(editor.script_id)
ScriptCheckSyntaxRequested(editor.script_id)
ScriptDeleteRequested(editor.script_id)
@guarantee HeaderLayout
-- Header bar with script title tab.
-- Actions (right side): Save button, Run button,
-- Check Syntax button, Delete button (danger style).
@guarantee MetadataRow
-- Two rows of metadata fields above the editor.
-- Row 1: Title (text input), Slug (text input, auto-generated from title).
-- Row 2: Kind (select: utility/macro/transform),
-- Entrypoint (select: dynamically discovered functions with "main" always first),
-- Enabled (checkbox).
@guarantee EditorBody
-- Code editor with syntax highlighting for the configured scripting language.
-- Toolbar: "Content" label.
-- Syntax errors shown as inline markers in editor gutter.
@guarantee FooterLayout
-- Created date and Updated date, locale-formatted.
}
-- ─── Script editor actions ──────────────────────────────────
rule ScriptSave {
when: ScriptSaveRequested(script_id)
-- Syntax check first using the configured scripting runtime semantics
-- If syntax error: blocks save, shows error inline in editor gutter
-- If valid: bumps version, saves to DB + rewrites the published script file
-- Entrypoint list re-discovered from AST after save
}
rule ScriptCheckSyntax {
when: ScriptCheckSyntaxRequested(script_id)
-- Validates script syntax without saving
-- Shows errors inline in editor gutter
-- Success shown as toast or status indicator
}
rule ScriptRun {
when: ScriptRunRequested(script_id)
-- Executes script in the configured runtime
-- Calls configured entrypoint function (default: main)
-- stdout/stderr directed to Output panel tab
-- Output panel auto-opens if not already visible
-- Errors shown in Output panel with line numbers
}
rule ScriptDelete {
when: ScriptDeleteRequested(script_id)
-- No confirmation dialog
-- Deletes DB record + published script file on disk
-- Closes script tab, sidebar removes item
}

View File

@@ -0,0 +1,271 @@
-- allium: 1
-- bDS Settings and Style Views
-- Scope: UI content area — settings + style editing surfaces
-- Distilled from: SettingsView.tsx, StyleView.tsx
-- Describes the layout and behaviour of the settings and style views.
use "./i18n.allium" as i18n
-- ─── Settings view ────────────────────────────────────────────
value SettingsView {
search_query: String? -- filters sections by keyword match
active_sections: List<String> -- visible sections after search filter
project_section: SettingsProjectSection?
editor_section: SettingsEditorSection?
categories: List<SettingsCategoryRow>
ai_section: SettingsAISection?
publishing_section: SettingsPublishingSection?
mcp_section: SettingsMCPSection?
}
value SettingsProjectSection {
project_name: String -- text input
project_description: String -- textarea (3 rows)
data_path: String -- text input + Browse button + Reset button
public_url: String -- url input
main_language: String -- select
blog_languages: List<String> -- checkboxes (main language disabled)
default_author: String -- text input
max_posts_per_page: Integer -- number input (1-500, default 50)
blogmark_category: String -- select from categories
}
value SettingsEditorSection {
default_mode: String -- select: wysiwyg | markdown | preview
diff_view_style: String -- select: inline | side-by-side
wrap_long_lines: Boolean -- checkbox
hide_unchanged_regions: Boolean -- checkbox
}
value SettingsCategoryRow {
name: String -- read-only for protected categories
title: String -- editable
render_in_lists: Boolean -- checkbox
show_titles: Boolean -- checkbox
post_template_slug: String? -- select
list_template_slug: String? -- select
is_protected: Boolean -- true for: article, aside, page, picture
}
value SettingsAISection {
anthropic_api_key: String? -- masked + Change/Save
mistral_api_key: String? -- masked + Change/Save
ollama_enabled: Boolean -- toggle, shows model capabilities table (tools/vision)
lm_studio_enabled: Boolean -- toggle, shows model capabilities table
offline_mode: Boolean -- toggle, switches between online and airplane endpoint
default_model: String? -- select from active endpoint models
title_model: String? -- select
image_analysis_model: String? -- select (vision-capable only)
system_prompt: String -- textarea (12 rows) + Save + Reset to Default
}
value SettingsPublishingSection {
ssh_mode: String -- select: scp | rsync
ssh_host: String -- text input
ssh_username: String -- text input
ssh_remote_path: String -- text input
}
value SettingsMCPSection {
status: String -- port number or "Not running"
agents: List<MCPAgentRow>
}
value MCPAgentRow {
agent_name: String -- Claude Code, Claude Desktop, GitHub Copilot, etc.
is_installed: Boolean -- Add/Remove toggle
}
invariant SettingsProtectedCategories {
-- Protected categories (article, aside, page, picture) cannot be deleted.
-- Their Remove button is disabled.
}
invariant SettingsMCPAgents {
-- MCP section has exactly 7 agent rows in this order:
-- Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI,
-- OpenCode, Mistral Vibe, OpenAI Codex.
}
config {
settings_max_posts_per_page: Integer = 500
settings_default_posts_per_page: Integer = 50
settings_system_prompt_rows: Integer = 12
}
surface SettingsViewSurface {
context settings: SettingsView
exposes:
settings.search_query
settings.active_sections
provides:
SettingsSearchChanged(query)
SettingsProjectSaved(project_data)
SettingsEditorSaved(editor_data)
SettingsCategoryAdded(category_name)
SettingsCategoryUpdated(category_row)
SettingsCategoryRemoved(category_name)
SettingsCategoriesResetToDefaults()
SettingsAIApiKeySaved(provider, key)
SettingsAIModelRefreshRequested(endpoint)
SettingsAISystemPromptSaved(prompt)
SettingsAISystemPromptReset()
SettingsPublishingSaved(publishing_data)
SettingsPublishingCleared()
SettingsMCPAgentToggled(agent_name)
SettingsRebuildRequested(entity_type)
SettingsRegenerateThumbnailsRequested()
SettingsOpenDataFolderRequested()
StyleThemeSelected(theme_name)
StyleApplyRequested(theme_name)
@guarantee SearchBar
-- Search input in header filters sections by keyword match.
-- "No results" message with clear button when no sections match.
@guarantee ProjectSection
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
-- Default Author, Max Posts Per Page (number 1-500),
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
@guarantee BookmarkletCopy
-- Copy button copies bookmarklet JavaScript to clipboard.
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
@guarantee EditorSection
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview),
-- Diff View Style (select: Inline/Side-by-side),
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).
@guarantee ContentCategoriesSection
-- Section 3: Table with columns: Category, Title, Render in Lists,
-- Show Titles, Post Template, List Template, Remove.
-- Protected categories (article, aside, page, picture) cannot be deleted.
-- Add Category: text input + Add button, validates non-empty and unique.
-- "Reset to Defaults" restores default categories set.
@guarantee AISection
-- Section 4: Anthropic API key, Mistral API key (both masked + Change/Save),
-- Ollama toggle (with model capabilities table: tools/vision),
-- LM Studio toggle (with capabilities table),
-- Offline mode toggle (with dedicated model selectors for chat/title/image),
-- Default model (with catalog info: max output, context window),
-- Title model, Image analysis model,
-- System prompt (textarea config.settings_system_prompt_rows rows + Save + Reset).
@guarantee AIApiKeyValidation
-- On Save: test API call to endpoint before persisting.
-- On failure: toast error message, key not saved.
-- On success: key encrypted via SecureKeyStore, masked display shown.
@guarantee AIModelRefresh
-- Refresh Models button per endpoint fetches model list from URL.
-- Model selector populated from fetched list.
-- Per-model info: max output tokens, context window (when available).
@guarantee TechnologySection
-- Section 5: scripting capabilities are configured at the application level;
-- this section does not expose implementation-specific runtime choices.
-- Semantic Similarity toggle.
@guarantee PublishingSection
-- Section 6: SSH Mode (scp/rsync), Host, Username, Remote Path.
-- Save + Clear buttons.
@guarantee MCPSection
-- Section 7: Status badge (port or "Not running").
-- 7 agent rows with Add/Remove toggle each.
@guarantee DataMaintenanceSection
-- Section 8: 6 rebuild buttons (Posts from Files, Media from Files,
-- Scripts from Files, Templates from Files, Links,
-- Regenerate Missing Thumbnails).
-- Open Data Folder button.
-- Each rebuild executes immediately (no confirmation).
-- Runs as background task with progress in Tasks panel.
@guarantee CollapsibleSections
-- All 8 sections are collapsible.
-- Section visibility respects search filter.
}
-- ─── Settings view actions ──────────────────────────────────
rule SettingsRebuild {
when: SettingsRebuildRequested(entity_type)
-- entity_type: posts | media | scripts | templates | links | thumbnails
-- Executes immediately (no confirmation dialog)
-- Runs as background task with progress visible in Tasks panel
-- On completion: wholesale replaces store data for that entity type
-- Sidebar and editors re-render with fresh data
}
-- ─── Style view ───────────────────────────────────────────────
value StyleView {
themes: List<StyleTheme>
selected_theme: String?
applied_theme: String?
preview_mode: String -- auto | light | dark
}
value StyleTheme {
name: String
accent_color: String -- hex
light_bg_color: String -- hex
dark_bg_color: String -- hex
}
surface StyleViewSurface {
context style: StyleView
exposes:
for t in style.themes:
t.name
t.accent_color
t.light_bg_color
t.dark_bg_color
style.selected_theme
style.applied_theme
style.preview_mode
provides:
StyleThemeSelected(theme_name)
StyleApplyRequested(style.selected_theme)
when style.selected_theme != style.applied_theme
StylePreviewModeChanged(mode)
@guarantee ThemePicker
-- Grid of theme buttons (one per Pico CSS theme).
-- Each button: swatch with 3 colour tones (accent, light bg, dark bg) + theme name.
-- Selected theme highlighted with aria-pressed.
@guarantee ControlsRow
-- Preview Mode dropdown (Auto/Light/Dark).
-- Apply Theme button, disabled when selected_theme matches applied_theme.
@guarantee LivePreview
-- Iframe at 127.0.0.1:4123/__style-preview with theme and mode query params.
-- Updates live on selection or preview mode change.
@guarantee PreviewModeLocal
-- Preview mode dropdown controls iframe query param only.
-- Does not persist — local UI state only.
}
rule StyleThemeSelect {
when: StyleThemeSelected(theme_name)
-- Updates iframe preview immediately (query param change)
-- Does NOT persist until Apply is clicked
}
rule StyleApplyTheme {
when: StyleApplyRequested(theme_name)
-- Persists picoTheme to project metadata (meta/project.json)
-- See engine_side_effects.allium UpdateProjectMetadataSideEffects
}

118
specs/editor_tags.allium Normal file
View File

@@ -0,0 +1,118 @@
-- allium: 1
-- bDS Tags View
-- Scope: UI content area — tag management surface
-- Distilled from: TagsView.tsx
-- Describes the layout and behaviour of the tags view.
use "./tag.allium" as tag
use "./i18n.allium" as i18n
-- ─── Tags view ────────────────────────────────────────────────
value TagsView {
cloud_tags: List<TagCloudItem>
selected_tags: List<String> -- multi-select from cloud
edit_form: TagEditForm? -- populated when exactly 1 tag selected
merge_source_tags: List<String> -- 2+ selected tags for merge
merge_target: String? -- target tag for merge
}
value TagCloudItem {
name: String
count: Integer
color: String?
}
value TagEditForm {
name: String -- editable text input
color: String? -- colour picker value
post_template_slug: String? -- select from available templates
}
value ColourPickerPopover {
presets: List<String> -- 17 hex colour values
custom_hex: String?
selected: String?
}
surface ColourPickerPopoverSurface {
context popover: ColourPickerPopover
exposes:
popover.presets
popover.custom_hex when popover.custom_hex != null
popover.selected when popover.selected != null
}
config {
colour_picker_preset_count: Integer = 17
tag_cloud_min_font: String = "0.85rem"
tag_cloud_max_font: String = "1.8rem"
}
surface TagsViewSurface {
context view: TagsView
exposes:
for t in view.cloud_tags:
t.name
t.count
t.color
view.selected_tags
view.edit_form when view.edit_form != null
view.merge_target when view.merge_target != null
provides:
TagCloudItemClicked(tag_name)
TagCreateRequested(name, color)
TagSaveRequested(name, color, post_template_slug)
when view.edit_form != null
TagDeleteRequested(tag_name)
when view.selected_tags.count = 1
TagMergeRequested(source_tags, target_tag)
when view.selected_tags.count >= 2
TagSyncRequested()
@guarantee CloudSection
-- Tag cloud visualisation (section #1).
-- Tags sized by post count (config.tag_cloud_min_font to config.tag_cloud_max_font).
-- Tags with colours get coloured background with contrast text.
-- Multi-select: click to toggle selection. Selection count shown with clear button.
-- Click selects tag for editing in Manage section.
@guarantee ManageSection
-- Create/Edit section (section #2).
-- Create form: name input + colour picker + Create button.
-- Edit form (when exactly 1 tag selected): name input, colour picker,
-- post template select dropdown (optional), Save/Cancel buttons.
-- Delete button visible when exactly 1 tag selected.
@guarantee ColourPicker
-- Inline popover positioned below tag colour field.
-- Grid of config.colour_picker_preset_count preset colour swatches.
-- Below grid: custom hex input field (#RRGGBB).
-- Selection is immediate — no confirm/cancel buttons.
-- Choosing a colour updates the form's colour field live.
@guarantee MergeSection
-- Merge section (section #3). Requires 2+ selected tags.
-- Target tag select dropdown (single select from selected tags).
-- Merge button opens ConfirmDialog:
-- "Merge N tags into {target}? This cannot be undone."
-- Shows preview of "tags to delete" (all selected except target).
@guarantee SyncSection
-- Sync/Discover section (section #4).
-- "Discover" button rewrites tags.json from current DB state.
@guarantee DeleteConfirmation
-- Delete opens ConfirmDialog showing post count:
-- "This tag is used in N posts. Delete anyway?"
-- On confirm: background task removes tag from all posts,
-- rewrites published .md files, deletes tag, writes tags.json.
@guarantee MergeExecution
-- On merge confirm: background task updates all affected posts,
-- rewrites published .md files, deletes source tags, writes tags.json.
}

View File

@@ -0,0 +1,87 @@
-- allium: 1
-- bDS Template Editor View
-- Scope: UI content area — template editing surface
-- Distilled from: TemplateEditor.tsx
-- Describes the layout and behaviour of the template editor.
-- See template.allium for entity model and Liquid subset.
use "./template.allium" as template
use "./i18n.allium" as i18n
-- ─── Template editor view ─────────────────────────────────────
value TemplateEditorView {
template_id: String
title: String -- editable text input
slug: String -- editable text input, auto-generated from title
kind: String -- select: post | list | not_found | partial
enabled: Boolean -- checkbox
content: String -- code editor content
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
surface TemplateEditorSurface {
context editor: TemplateEditorView
exposes:
editor.title
editor.slug
editor.kind
editor.enabled
editor.created_at
editor.updated_at
provides:
TemplateSaveRequested(editor.template_id)
TemplateValidateRequested(editor.template_id)
TemplateDeleteRequested(editor.template_id)
@guarantee HeaderLayout
-- Header bar with template title tab.
-- Actions (right side): Save button, Validate button,
-- Delete button (danger style).
@guarantee MetadataRow
-- Two rows of metadata fields above the editor.
-- Row 1: Title (text input), Slug (text input, auto-generated from title).
-- Row 2: Kind (select: post/list/not-found/partial), Enabled (checkbox).
@guarantee EditorBody
-- Code editor with HTML/Liquid syntax highlighting.
-- Toolbar: "Content" label.
-- Syntax errors shown inline in editor on validation.
@guarantee FooterLayout
-- Created date and Updated date, locale-formatted.
}
-- ─── Template editor actions ────────────────────────────────
rule TemplateSave {
when: TemplateSaveRequested(template_id)
-- Liquid validation first (parse check for syntax errors)
-- If invalid: blocks save, shows error inline in editor
-- If valid: bumps version, saves to DB + rewrites .liquid file
-- See engine_side_effects.allium UpdateTemplateSideEffects
}
rule TemplateValidate {
when: TemplateValidateRequested(template_id)
-- Validates Liquid syntax without saving
-- Shows errors inline in editor
-- Success shown as toast or status indicator
}
rule TemplateDelete {
when: TemplateDeleteRequested(template_id)
-- Checks for references: posts using this template, tags with postTemplateSlug
-- If references exist: system confirm dialog
-- "This template is used by N posts and M tags. Force delete?"
-- Force delete: nulls templateSlug on referencing posts,
-- nulls postTemplateSlug on referencing tags
-- If no references: deletes without confirmation
-- Deletes DB record + .liquid file on disk
-- Closes template tab, sidebar removes item
}

226
specs/embedding.allium Normal file
View File

@@ -0,0 +1,226 @@
-- allium: 1
-- bDS Semantic Similarity / Embeddings
-- Scope: extension (Bucket D — Embeddings + Duplicate Detection)
-- Distilled from: src/main/engine/EmbeddingEngine.ts
-- Local embedding model for semantic similarity. Runs entirely on-device,
-- independent of AI endpoints. Gated by semanticSimilarityEnabled project setting.
use "./post.allium" as post
use "./tag.allium" as tag
surface EmbeddingModelSurface {
context model: EmbeddingModel
exposes:
model.model_id
model.dimensions
}
surface EmbeddingRuntimeSurface {
facing _: EmbeddingRuntime
provides:
PostCreated(post)
PostUpdated(post)
PostDeleted(post)
}
surface EmbeddingControlSurface {
facing _: EmbeddingOperator
provides:
ReindexAllRequested(project)
IndexUnindexedRequested(project)
FindSimilarRequested(post, limit)
ComputeSimilaritiesRequested(source_post, target_post_ids)
SuggestTagsRequested(post, input_text)
FindDuplicatesRequested(project)
DismissDuplicatePairRequested(post_a, post_b)
}
-- ─── Model ──────────────────────────────────────────────────
value EmbeddingModel {
-- multilingual-e5-small: 384-dimensional sentence embeddings
-- Model files are obtained from an external model source and cached locally
-- Downloaded on first use, cached in app data directory
-- Lazy-loaded: pipeline created on first embedding request, not at startup
-- Text preprocessing: prefix all input with "query: " (e5 convention)
-- Pooling: mean pooling + L2 normalization
model_id: String -- "Xenova/multilingual-e5-small"
dimensions: Integer -- 384
}
value EmbeddingVector {
dimensions: Integer -- 384 (multilingual-e5-small)
values: List<Decimal>
}
-- ─── Entities ───────────────────────────────────────────────
entity EmbeddingKey {
label: Integer -- HNSW label for USearch
post: post/Post
content_hash: String -- SHA-256 of "{title}\n\n{content}"
vector: EmbeddingVector
}
entity DismissedDuplicatePair {
post_a: post/Post
post_b: post/Post
-- IDs stored in canonical order (sorted) for dedup
}
-- ─── USearch HNSW Index ─────────────────────────────────────
config {
model_id: String = "Xenova/multilingual-e5-small"
embedding_dimensions: Integer = 384
hnsw_metric: String = "cosine"
hnsw_connectivity: Integer = 16 -- M parameter
hnsw_expansion_add: Integer = 128 -- efConstruction
hnsw_expansion_search: Integer = 64 -- efSearch
debounce_persist: Duration = 5.seconds
-- Index file: {userData}/projects/{projectId}/embeddings.usearch
-- Key mapping is persisted alongside the embedding records
}
-- ─── Gating ─────────────────────────────────────────────────
invariant SemanticSimilarityGate {
-- All embedding operations are gated by semanticSimilarityEnabled in project metadata.
-- When disabled: no posts are indexed, queries return empty results.
-- When toggled on: triggers IndexUnindexed to backfill all posts.
-- When toggled off: index remains on disk but is not queried.
}
-- ─── Event-driven indexing ──────────────────────────────────
-- Post lifecycle events trigger embedding updates automatically.
-- See engine_side_effects.allium for the trigger points.
rule EmbedPost {
when: PostCreated(post) or PostUpdated(post)
requires: semantic_similarity_enabled
let hash = sha256(format("{title}\n\n{content}", title: post.title, content: post.content))
let existing = EmbeddingKey{post: post}
if not exists existing or existing.content_hash != hash:
-- Compute embedding vector via local model
-- Upsert into USearch index + embedding_keys DB table
-- Debounced index save (5s)
ensures: EmbeddingKeyUpdated(post)
}
rule RemovePostEmbedding {
when: PostDeleted(post)
requires: semantic_similarity_enabled
ensures: EmbeddingKeyRemoved(post)
}
-- ─── Batch indexing ─────────────────────────────────────────
rule ReindexAll {
when: ReindexAllRequested(project)
requires: semantic_similarity_enabled
-- Re-embeds all posts, rebuilds HNSW index from scratch
for p in project.posts:
ensures: EmbeddingKeyUpdated(p)
ensures: HnswIndexRebuilt(project)
}
rule IndexUnindexed {
when: IndexUnindexedRequested(project)
requires: semantic_similarity_enabled
-- Triggered at app startup (if enabled) and when setting toggled on
-- Only embeds posts without existing embeddings or with changed content_hash
-- Runs as background task with progress reporting
for p in project.posts:
let existing = EmbeddingKey{post: p}
if not exists existing or existing.content_hash != p.checksum:
ensures: EmbeddingKeyUpdated(p)
}
-- ─── Query operations ───────────────────────────────────────
rule FindSimilar {
when: FindSimilarRequested(post, limit)
requires: semantic_similarity_enabled
-- HNSW approximate nearest neighbor search via USearch
-- Searches index for (limit + 1) neighbors, excludes self
-- Converts USearch cosine distance to similarity: max(0, 1 - distance)
-- Returns ranked list sorted by descending similarity
ensures: SimilarPostsResult(post, ranked_matches)
}
rule ComputeSimilarities {
when: ComputeSimilaritiesRequested(source_post, target_post_ids)
requires: semantic_similarity_enabled
-- Exact pairwise cosine similarity between source vector and each target vector
-- Uses in-memory vector cache, NOT USearch search
-- Returns map of post_id -> similarity score
-- Used by InsertPostLinkModal to rank FTS search results
ensures: SimilarityScoresResult(source_post, scores)
}
rule SuggestTags {
when: SuggestTagsRequested(post, input_text)
requires: semantic_similarity_enabled
-- 1. Find 10 most similar posts via HNSW search
-- 2. Collect all tags from those posts
-- 3. Weight tags by similarity score of the post they came from
-- 4. Return top 5 tags by weighted score
-- Used by tag input component for autocomplete suggestions
ensures: TagSuggestionResult(post, suggested_tags)
}
rule FindDuplicates {
when: FindDuplicatesRequested(project)
requires: semantic_similarity_enabled
-- Finds near-duplicate post pairs above similarity threshold
-- For each indexed post: search 21 nearest neighbors
-- Pairs above 0.92 threshold kept, dismissed pairs excluded
-- At 100% embedding similarity: loads post bodies for exact match check
-- Results sorted: exact matches first, then descending similarity
let all_pairs = compute_all_similarities(project)
let above_threshold = filter_above_threshold(all_pairs)
let pairs = exclude_dismissed(above_threshold, DismissedDuplicatePairs)
ensures: DuplicateReport(pairs)
}
rule DismissDuplicatePair {
when: DismissDuplicatePairRequested(post_a, post_b)
-- Stores with canonical ID ordering for consistent dedup
ensures: DismissedDuplicatePair.created(post_a: post_a, post_b: post_b)
}
-- ─── Invariants ─────────────────────────────────────────────
invariant ContentHashSkipsUnchanged {
-- If a post's content_hash matches the stored embedding's content_hash,
-- the post is not re-embedded. This makes bulk re-indexing efficient.
}
invariant DebouncedPersistence {
-- USearch index persistence is debounced at 5 seconds
-- Prevents excessive disk I/O during bulk operations
-- Index also force-saved on project switch and app shutdown
}
invariant VectorCacheInDb {
-- Vector cache persisted as BLOB in embedding_keys table
-- Float32Array, 384 dimensions per vector (1536 bytes)
-- Enables instant reload without re-embedding
}
invariant ModelCaching {
-- Model files (~100 MB) downloaded from Hugging Face Hub on first use
-- Cached in app data directory, persists across sessions
-- Model pipeline stays loaded across project switches (one model, many indexes)
}
invariant ProjectIsolation {
-- Each project has its own USearch index file and embedding_keys rows
-- On project switch: save current index, load new project's index
-- Model pipeline shared across projects (not reloaded)
}

View File

@@ -0,0 +1,314 @@
-- allium: 1
-- bDS Engine-Level Save Side-Effects
-- Scope: cross-cutting (all waves)
-- Distilled from: PostEngine.ts, MediaEngine.ts, TemplateEngine.ts,
-- ScriptEngine.ts, MetaEngine.ts, TagEngine.ts
-- When an entity is saved/published/deleted in the engine layer, a chain
-- of automatic side-effects fires. These are NOT UI-level concerns —
-- they happen in the backend regardless of which UI triggered them.
use "./post.allium" as post
use "./media.allium" as media
-- ─── External surfaces ──────────────────────────────────────
-- Engine-level events emitted after backend operations complete.
-- These are NOT direct user actions — they fire as side-effects
-- of user operations processed by the engine layer.
surface Engine {
provides: PostCreated(post)
provides: PostUpdated(post, changes)
provides: PostPublished(post)
provides: PostDeleted(post)
provides: PostChangesDiscarded(post)
provides: MediaImported(media)
provides: MediaUpdated(media, changes)
provides: MediaFileReplaced(media, new_file)
provides: MediaDeleted(media)
provides: TemplateCreated(template)
provides: TemplateUpdated(template, changes)
provides: TemplatePublished(template)
provides: TemplateDeleted(template, force)
provides: TagDeleted(tag)
provides: TagRenamed(old_name, new_name)
provides: TagsMerged(source_tags, target_tag)
provides: ProjectMetadataUpdated(metadata)
provides: CategoryAdded(name)
provides: CategoryRemoved(name)
provides: PublishingPreferencesUpdated(prefs)
provides: PostTranslationUpserted(translation, source_post)
provides: MediaTranslationUpserted(translation, media)
provides: MediaTranslationDeleted(media, language)
}
-- ─── Post operations ─────────────────────────────────────
rule CreatePostSideEffects {
when: PostCreated(post)
ensures: FTSIndexUpdated(post)
ensures: EmbeddingUpdated(post)
-- No file written (draft lives in DB)
}
rule UpdatePostSideEffects {
when: PostUpdated(post, changes)
-- If post is published and content/metadata changes:
-- auto-transition status back to draft
-- If slug changed and file exists: rename .md file
-- If templateSlug changed on published post: rewrite .md frontmatter
ensures: FTSIndexUpdated(post)
if changes.content:
ensures: PostLinksUpdated(post)
-- Parses markdown/HTML links, resolves slugs to post IDs,
-- replaces outgoing link rows
ensures: EmbeddingUpdated(post)
}
rule PublishPostSideEffects {
when: PostPublished(post)
ensures: PostFileWritten(post)
-- posts/YYYY/MM/{slug}.md with YAML frontmatter
if old_file_path != new_file_path:
ensures: OldPostFileDeleted(old_file_path)
ensures: post.content = null
-- Content cleared from DB; lives in filesystem only
ensures: FTSIndexUpdated(post)
ensures: PostLinksUpdated(post)
-- Also publishes all translations:
for t in post.translations:
ensures: TranslationFileWritten(t)
ensures: t.content = null
ensures: EmbeddingUpdated(post)
}
rule DeletePostSideEffects {
when: PostDeleted(post)
if post.file_path != "":
ensures: PostFileDeleted(post.file_path)
ensures: PostLinksDeleted(post)
-- Deletes both source and target link rows
for media_link in post.linked_media:
ensures: MediaSidecarUpdated(media_link.media_id)
-- Removes post from media sidecar's linkedPostIds
ensures: FTSIndexDeleted(post)
ensures: EmbeddingRemoved(post)
}
rule DiscardPostChangesSideEffects {
when: PostChangesDiscarded(post)
-- Reads published version from file, restores DB metadata,
-- sets content=null, status=published
ensures: FTSIndexUpdated(post)
}
-- ─── Media operations ────────────────────────────────────
rule ImportMediaSideEffects {
when: MediaImported(media)
ensures: MediaFileWritten(media)
-- media/YYYY/MM/{uuid}.{ext}
ensures: SidecarFileWritten(media)
-- {path}.meta with YAML-like metadata
if media.is_image:
ensures: ThumbnailsGenerated(media)
-- small=150px, medium=400px, large=800px, ai=448x448
-- Asynchronous, emits thumbnailsGenerated on completion
ensures: FTSIndexUpdated(media)
}
rule UpdateMediaSideEffects {
when: MediaUpdated(media, changes)
ensures: SidecarFileRewritten(media)
-- Preserves fields caller didn't set (linkedPostIds, author)
ensures: FTSIndexUpdated(media)
}
rule ReplaceMediaFileSideEffects {
when: MediaFileReplaced(media, new_file)
-- Copies new file over existing path
if media.is_image:
ensures: ThumbnailsRegenerated(media)
-- Synchronous (awaited), not fire-and-forget
}
rule DeleteMediaSideEffects {
when: MediaDeleted(media)
ensures: MediaFileDeleted(media)
ensures: SidecarFileDeleted(media)
ensures: ThumbnailsDeleted(media)
ensures: PostMediaLinksDeleted(media)
ensures: MediaTranslationsDeleted(media)
-- Also deletes all translated sidecar files: {path}.{lang}.meta
ensures: FTSIndexDeleted(media)
}
-- ─── Template operations ─────────────────────────────────
rule CreateTemplateSideEffects {
when: TemplateCreated(template)
ensures: TemplateFileWritten(template)
-- templates/{slug}.liquid with YAML frontmatter
}
rule UpdateTemplateSideEffects {
when: TemplateUpdated(template, changes)
ensures: template.version = template.version + 1
-- DB-first update, then filesystem; rollback DB on filesystem failure
if changes.slug:
ensures: TemplateFileRenamed(template)
ensures: CascadeSlugUpdate(template)
-- Updates posts.templateSlug and tags.postTemplateSlug
ensures: TemplateFileRewritten(template)
}
rule PublishTemplateSideEffects {
when: TemplatePublished(template)
ensures: TemplateFileWritten(template)
ensures: template.content = null
-- Content cleared from DB
}
rule DeleteTemplateSideEffects {
when: TemplateDeleted(template, force)
if has_references and not force:
-- Return without deleting, report reference counts
ensures: nothing
if force:
ensures: ReferencingPostsCleared(template)
ensures: ReferencingTagsCleared(template)
-- Nulls out templateSlug on posts, postTemplateSlug on tags
ensures: TemplateFileDeleted(template)
}
-- ─── Script operations ───────────────────────────────────
-- Same pattern as templates:
-- Create: write published script file, insert DB
-- Update: bump version, rewrite file, update DB
-- Publish: write file, clear DB content
-- Delete: delete file, delete DB row
-- ─── Tag operations ──────────────────────────────────────
rule DeleteTagSideEffects {
when: TagDeleted(tag)
-- Background task:
-- For each post containing this tag:
-- Remove tag from post's tags array in DB
-- If published: rewrite .md file (syncPublishedPostFile)
ensures: TagsJsonWritten()
-- meta/tags.json updated
}
rule RenameTagSideEffects {
when: TagRenamed(old_name, new_name)
-- Background task:
-- For each post containing old_name:
-- Replace old_name with new_name in tags array
-- If published: rewrite .md file
ensures: TagsJsonWritten()
}
rule MergeTagsSideEffects {
when: TagsMerged(source_tags, target_tag)
-- Background task:
-- For each source tag, for each post containing it:
-- Replace source with target (dedup), update DB
-- If published: rewrite .md file
-- Delete all source tag rows
ensures: TagsJsonWritten()
}
-- ─── Settings/Metadata operations ────────────────────────
rule UpdateProjectMetadataSideEffects {
when: ProjectMetadataUpdated(metadata)
ensures: ProjectJsonWritten()
-- meta/project.json (atomic write)
ensures: CategoryMetaJsonWritten()
-- meta/category-meta.json (atomic write)
}
rule AddCategorySideEffects {
when: CategoryAdded(name)
ensures: ProjectJsonWritten()
ensures: CategoryMetaJsonWritten()
ensures: CategoriesJsonWritten()
-- meta/categories.json
}
rule RemoveCategorySideEffects {
when: CategoryRemoved(name)
ensures: ProjectJsonWritten()
ensures: CategoryMetaJsonWritten()
ensures: CategoriesJsonWritten()
}
rule UpdatePublishingPreferencesSideEffects {
when: PublishingPreferencesUpdated(prefs)
ensures: PublishingJsonWritten()
-- meta/publishing.json (atomic write)
}
-- ─── Translation operations ──────────────────────────────
rule UpsertPostTranslationSideEffects {
when: PostTranslationUpserted(translation, source_post)
-- If source is published and this is a manual edit (not auto-publish):
-- transition source post to draft (copies content from file to DB)
if source_post.status = published and translation.is_manual_edit:
ensures: source_post.status = draft
ensures: source_post.content = read_file(source_post.file_path)
-- If both translation and source are published:
-- write translation file, clear translation content from DB
if translation.status = published and source_post.status = published:
ensures: TranslationFileWritten(translation)
ensures: translation.content = null
ensures: FTSIndexUpdated(source_post)
-- FTS includes all translation content for the source post
}
rule UpsertMediaTranslationSideEffects {
when: MediaTranslationUpserted(translation, media)
ensures: TranslatedSidecarWritten(media, translation.language)
-- {path}.{lang}.meta
}
rule DeleteMediaTranslationSideEffects {
when: MediaTranslationDeleted(media, language)
ensures: TranslatedSidecarDeleted(media, language)
}
-- ─── CLI/MCP notification sync ───────────────────────────
-- When MCP CLI makes mutations, it writes to db_notifications table.
-- NotificationWatcher polls DB file (chokidar, 100ms debounce):
-- Reads unseen CLI notifications
-- Calls engine.invalidate(entityId) if needed
-- Sends entity:changed IPC event to renderer
-- Marks rows as seen
-- Prunes: >1h processed, >24h unprocessed
-- ─── Side-effect summary table ───────────────────────────
-- Operation | File Write | FTS | Links | Thumbs | Sidecar | Embed | JSON Meta
-- -------------------|--------------|------|-------|--------|---------|-------|----------
-- createPost | no (draft) | yes | no | no | no | yes | no
-- updatePost | rename only* | yes | if Δ | no | no | yes | no
-- publishPost | .md + trans | yes | yes | no | no | yes | no
-- deletePost | delete .md | del | del | no | Δ media | del | no
-- importMedia | copy file | yes | no | async | write | no | no
-- updateMedia | no | yes | no | no | rewrite | no | no
-- replaceMediaFile | overwrite | no | no | regen | no | no | no
-- deleteMedia | delete all | del | no | del | del all | no | no
-- createTemplate | .liquid | no | no | no | no | no | no
-- updateTemplate | rewrite | no | no | no | no | no | no
-- deleteTemplate | delete+casc | no | no | no | no | no | no
-- deleteTag | sync posts | no | no | no | no | no | tags.json
-- renameTag | sync posts | no | no | no | no | no | tags.json
-- mergeTags | sync posts | no | no | no | no | no | tags.json
-- updateMetadata | no | no | no | no | no | no | *.json
-- addCategory | no | no | no | no | no | no | *.json
-- * updatePost rewrites file only when templateSlug changes on published post

394
specs/frontmatter.allium Normal file
View File

@@ -0,0 +1,394 @@
-- allium: 1
-- bDS Frontmatter Specifications
-- Scope: core (Wave 1 — exact file format compatibility)
-- Distilled from: ../bDS/src/main/engine/postFileUtils.ts,
-- TemplateEngine.ts, ScriptEngine.ts, MediaEngine.ts
--
-- This document specifies the exact YAML frontmatter format for all
-- file types. The rewrite must read and write these formats compatibly
-- with existing bDS content.
surface FrontmatterPersistenceSurface {
facing _: ContentPersistenceRuntime
provides:
PublishPostRequested(post)
PublishTemplateRequested(template)
PublishScriptRequested(script)
}
surface PostFrontmatterSurface {
context frontmatter: PostFrontmatter
exposes:
frontmatter.id
frontmatter.title
frontmatter.slug
frontmatter.status
frontmatter.published_at
frontmatter.tags
frontmatter.categories
}
surface MediaSidecarSurface {
context sidecar: MediaSidecar
exposes:
sidecar.id
sidecar.original_name
sidecar.mime_type
sidecar.width
sidecar.height
sidecar.updated_at
}
surface TemplateFrontmatterSurface {
context frontmatter: TemplateFrontmatter
exposes:
frontmatter.id
frontmatter.slug
frontmatter.kind
frontmatter.enabled
frontmatter.version
}
surface ScriptFrontmatterSurface {
context frontmatter: ScriptFrontmatter
exposes:
frontmatter.id
frontmatter.slug
frontmatter.kind
frontmatter.entrypoint
frontmatter.enabled
frontmatter.version
}
surface MenuOpmlSurface {
context document: MenuOpml
exposes:
document.header.title
document.header.date_created
document.header.date_modified
for item in document.body:
item.kind
item.label
item.slug
}
config {
script_extension: String = "script"
}
-- ============================================================================
-- POST FILE FORMAT
-- ============================================================================
value PostFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.md
-- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md
id: String -- UUID v4
title: String
slug: String
excerpt: String? -- Optional, only written if present
status: draft | published | archived
author: String? -- Only written if present
language: String? -- Only written if present (ISO 639-1)
do_not_translate: Boolean -- Only written when true
template_slug: String? -- Only written if present
created_at: Timestamp -- Unix timestamp in milliseconds
updated_at: Timestamp -- Unix timestamp in milliseconds
published_at: Timestamp? -- Only written if published
tags: List<String> -- Always written, even if empty
categories: List<String> -- Always written, even if empty
}
invariant PostFileLayout {
-- Posts are stored in date-based directory structure
-- YYYY and MM derived from created_at (zero-padded)
for p in Posts where file_path != "":
p.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
yyyy: p.created_at.year,
mm: p.created_at.month_padded,
slug: p.slug)
}
invariant PostTranslationFileLayout {
-- Translations use the same directory structure with language suffix
for t in PostTranslations where file_path != "":
t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md",
yyyy: t.canonical_post.created_at.year,
mm: t.canonical_post.created_at.month_padded,
slug: t.canonical_post.slug,
lang: t.language)
}
rule WritePostFile {
when: PublishPostRequested(post)
ensures: FileWritten(
path: post.file_path,
content: format_post_file(post)
)
ensures: post.content = null
-- Content moved from DB to filesystem
}
-- ============================================================================
-- MEDIA SIDECAR FORMAT
-- ============================================================================
value MediaSidecar {
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter)
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
id: String -- UUID v4
original_name: String -- Original uploaded filename
mime_type: String
size: Integer -- Bytes
width: Integer?
height: Integer?
title: String? -- Only written if present
alt: String? -- Only written if present
caption: String? -- Only written if present
author: String? -- Only written if present
language: String? -- Only written if present
tags: List<String> -- Always written, even if empty
created_at: Timestamp
updated_at: Timestamp
}
invariant MediaSidecarLayout {
for m in Media:
m.sidecar_path = format("{binary_path}.meta", binary_path: m.file_path)
}
-- ============================================================================
-- TEMPLATE FILE FORMAT
-- ============================================================================
value TemplateFrontmatter {
-- File path: templates/{slug}.liquid
id: String -- UUID v4
slug: String
title: String
kind: post | list | not_found | partial
enabled: Boolean
version: Integer
created_at: Timestamp
updated_at: Timestamp
}
rule WriteTemplateFile {
when: PublishTemplateRequested(template)
requires: ValidateLiquid(template.content) = valid
ensures: FileWritten(
path: format("templates/{slug}.liquid", slug: template.slug),
content: format_template_file(template)
)
ensures: template.content = null
}
-- ============================================================================
-- SCRIPT FILE FORMAT
-- ============================================================================
value ScriptFrontmatter {
-- File path: scripts/{slug}.{extension}
-- YAML frontmatter delimited by --- markers
id: String -- UUID v4
slug: String
title: String
kind: macro | utility | transform
entrypoint: String -- Default: "render"
enabled: Boolean
version: Integer
created_at: Timestamp
updated_at: Timestamp
}
rule WriteScriptFile {
when: PublishScriptRequested(script)
requires: ValidateScript(script.content) = valid
ensures: FileWritten(
path: format("scripts/{slug}.{extension}", slug: script.slug, extension: config.script_extension),
content: format_script_file(script)
)
ensures: script.content = null
}
-- ============================================================================
-- TAGS FILE FORMAT
-- ============================================================================
value TagsFile {
-- File path: meta/tags.json
-- Portable JSON format (no internal IDs)
tags: List<TagEntry>
}
value TagEntry {
name: String
color: String?
post_template_slug: String?
}
invariant TagsFileFormat {
-- Tags are stored as a sorted JSON array
-- Sorted alphabetically by name (case-insensitive)
parse_json(read_file("meta/tags.json")) = {
tags: sort_by(Tags, t => lowercase(t.name))
}
}
-- ============================================================================
-- PROJECT METADATA FILES
-- ============================================================================
value ProjectJson {
-- File path: meta/project.json
name: String
description: String?
public_url: String?
main_language: String?
default_author: String?
max_posts_per_page: Integer
blogmark_category: String?
pico_theme: String?
semantic_similarity_enabled: Boolean
blog_languages: List<String>
}
value CategoriesJson {
-- File path: meta/categories.json
-- Sorted list of category names
categories: List<String>
}
value CategoryMetaJson {
-- File path: meta/category-meta.json
-- Per-category render settings
categories: Map<String, CategorySettings>
}
value CategorySettings {
render_in_lists: Boolean
show_title: Boolean
post_template_slug: String?
list_template_slug: String?
}
value PublishingJson {
-- File path: meta/publishing.json
ssh_host: String?
ssh_user: String?
ssh_remote_path: String?
ssh_mode: scp | rsync
}
invariant MetadataFileLayout {
-- All metadata files in meta/ directory
-- Each file is written atomically (temp file + rename)
meta/project.json = serialize(ProjectJson)
meta/categories.json = serialize(CategoriesJson)
meta/category-meta.json = serialize(CategoryMetaJson)
meta/publishing.json = serialize(PublishingJson)
meta/menu.opml = serialize(Menu)
meta/tags.json = serialize(TagsFile)
}
-- ============================================================================
-- MENU FILE FORMAT
-- ============================================================================
value MenuOpml {
-- File path: meta/menu.opml
-- OPML 2.0 format with outline elements
header: OpmlHeader
body: List<MenuItem>
}
value OpmlHeader {
title: String
date_created: Timestamp
date_modified: Timestamp
}
value MenuItem {
kind: page | submenu | category_archive | home
label: String
slug: String?
children: List<MenuItem>?
}
invariant MenuOpmlFormat {
-- Menu is stored as OPML with Home always first
-- Note: List literal syntax not supported in Allium
-- Actual structure: header + body with MenuItem elements
}
-- ============================================================================
-- FILE FORMAT CONVENTIONS
-- ============================================================================
invariant TimestampFormat {
-- Database: Unix milliseconds stored as INTEGER columns
-- YAML frontmatter: ISO 8601 strings (e.g. 2024-03-15T14:30:00.000Z)
-- Conversion on read: parse ISO 8601 → Unix ms
-- Conversion on write: Unix ms → ISO 8601
}
invariant YamlFormatting {
-- YAML frontmatter uses 2-space indentation
-- Arrays use YAML list syntax: - item1\n- item2
-- Strings with special characters are quoted
-- Boolean values are lowercase: true/false
}
invariant AtomicWrites {
-- All file writes are atomic
-- Write to temp file first, then rename
-- Prevents corruption from interrupted writes
}
-- ============================================================================
-- FRONTmatter FIELD RULES
-- ============================================================================
invariant RequiredPostFields {
-- These fields are ALWAYS written for posts
for p in Posts:
required_fields(p) = {
id, title, slug, status, created_at, updated_at,
tags, categories
}
}
invariant ConditionalPostFields {
-- These fields are ONLY written if truthy
for p in Posts:
conditional_fields(p) = {
excerpt, author, language, template_slug, published_at
}
-- do_not_translate is only written when true
}
invariant RequiredMediaFields {
-- These fields are ALWAYS written for media sidecars
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself
for m in Media:
required_fields(m) = {
id, original_name, mime_type, size,
created_at, updated_at, tags
}
}
invariant ConditionalMediaFields {
-- These fields are ONLY written if truthy
for m in Media:
conditional_fields(m) = {
title, alt, caption, author, language, width, height
}
}

231
specs/generation.allium Normal file
View File

@@ -0,0 +1,231 @@
-- allium: 1
-- bDS Static Site Generation
-- Scope: core (Wave 4)
-- Distilled from: src/main/engine/BlogGenerationEngine.ts,
-- PageRenderer.ts, GenerationWorkerPool, RoutePageGenerationService
use "./post.allium" as post
use "./template.allium" as template
use "./metadata.allium" as meta
use "./menu.allium" as menu
use "./translation.allium" as translation
surface GenerationControlSurface {
facing _: GenerationOperator
provides:
GenerateSiteRequested(generation)
ValidateSiteRequested(project)
ApplyValidationRequested(project_id, sections)
}
surface GenerationRuntimeSurface {
facing _: GenerationRuntime
provides:
PageRenderRequested(template, context)
GenerateSiteCompleted(generation)
}
value GenerationSection {
kind: core | single | category | tag | date
}
value GeneratedFile {
relative_path: String
content_hash: String
}
entity SiteGeneration {
project_id: String
base_url: String
language: String -- main language
blog_languages: Set<String>
max_posts_per_page: Integer
pico_theme: String?
sections: Set<GenerationSection>
-- Output tracking
generated_files: GeneratedFile with project_id = this.project_id
}
surface GenerationStatusSurface {
context generation: SiteGeneration
exposes:
generation.project_id
generation.base_url
generation.language
generation.blog_languages
generation.max_posts_per_page
generation.pico_theme
generation.sections
generation.generated_files.count
}
invariant IncrementalByContentHash {
-- Files are only written when content_hash changes
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
-- A file with unchanged hash is skipped on regeneration
}
invariant MultiLanguageRoutes {
-- Main language: flat routes (/{yyyy}/{mm}/{dd}/{slug})
-- Additional languages: prefixed (/{lang}/{yyyy}/{mm}/{dd}/{slug})
-- Each language subtree gets its own feeds and archives
}
invariant CanonicalBaseUrlConfigured {
for generation in SiteGenerations:
generation.base_url != ""
}
invariant NamedPicoTheme {
for generation in SiteGenerations where generation.pico_theme != null:
generation.pico_theme != ""
}
invariant GeneratedFilesTracked {
for generation in SiteGenerations:
generation.generated_files.count >= 0
}
-- Core section: root pages, sitemap, RSS, Atom, calendar.json
rule GenerateCoreSectionPages {
when: GenerateSiteRequested(generation)
requires: core in generation.sections
ensures: FileGenerated("index.html")
ensures: FileGenerated("sitemap.xml")
-- Multi-language sitemap with hreflang alternates
ensures: FileGenerated("feed.xml")
-- RSS 2.0 feed
ensures: FileGenerated("atom.xml")
-- Atom feed
ensures: FileGenerated("calendar.json")
-- Post dates for calendar widget
for lang in generation.blog_languages - {generation.language}:
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
}
-- Single section: one HTML page per published post
rule GenerateSinglePostPages {
when: GenerateSiteRequested(generation)
requires: single in generation.sections
for p in Posts where status = published:
let url = post_canonical_url(p)
ensures: FileGenerated(format("{url}/index.html", url: url))
for lang in generation.blog_languages - {generation.language}:
if p.translations.any(t => t.language.code = lang):
ensures: FileGenerated(format("{lang}/{url}/index.html",
lang: lang, url: url))
}
-- Category section: paginated archive per category
rule GenerateCategoryPages {
when: GenerateSiteRequested(generation)
requires: category in generation.sections
for cat in generation.categories:
let page_count = ceil(posts_in_category(cat).count / generation.max_posts_per_page)
ensures: FileGenerated(format("category/{cat}/index.html", cat: cat))
for page in page_range(2, page_count):
ensures: FileGenerated(format("category/{cat}/page/{page}/index.html",
cat: cat, page: page))
}
-- Tag section: paginated archive per tag
rule GenerateTagPages {
when: GenerateSiteRequested(generation)
requires: tag in generation.sections
for t in Tags where post_count > 0:
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name)))
}
-- Date section: year and month archives
rule GenerateDateArchivePages {
when: GenerateSiteRequested(generation)
requires: date in generation.sections
for year in distinct_years(Posts):
ensures: FileGenerated(format("{year}/index.html", year: year))
for month in distinct_months(Posts, year):
ensures: FileGenerated(format("{year}/{month}/index.html",
year: year, month: month))
}
-- Template rendering context
rule RenderPage {
when: PageRenderRequested(template, context)
-- Template rendering with full context:
-- posts, pagination, menus, tags, categories,
-- project metadata, i18n translations, theme settings
-- Macro expansion: [[slug param1=value1 ...]] in post content
-- HTML rewriting for canonical post/media paths
ensures: RenderedHtml(template, context, output)
}
-- Validation
rule ValidateSite {
when: ValidateSiteRequested(project)
-- Compares sitemap URLs to HTML files on disk
-- Detects: missing pages, extra (stale) pages, sitemap/file mismatches
ensures: ValidationReport(missing_pages, extra_pages, stale_pages)
}
rule ApplyValidation {
when: ApplyValidationRequested(project_id, sections)
-- Targeted re-rendering for affected sections only
ensures: GenerateSiteRequested(plan_generation(project_id, sections))
}
-- Day-block grouping for archives
invariant ArchiveDayBlocks {
-- Archive/list pages group posts by day
-- Each day block has a date header and the posts for that day
}
-- ============================================================================
-- SEARCH INDEX: PAGEFIND
-- ============================================================================
-- Pagefind builds a client-side full-text search index from generated HTML.
-- Uses an embedded Pagefind library integration rather than a CLI subprocess.
-- Runs as the final step of the generation pipeline, after all HTML is written.
rule BuildSearchIndex {
when: GenerateSiteCompleted(generation)
-- Only runs if any pages were rendered or deleted in this generation pass.
-- Separate index per language:
-- Main language: source = {html}/, output = {html}/pagefind/
-- Each additional language: source = {html}/{lang}/, output = {html}/{lang}/pagefind/
-- Each index built with force_language set to that language code.
for lang in {generation.language} + (generation.blog_languages - {generation.language}):
ensures: PagefindIndexBuilt(lang)
}
invariant PagefindHtmlMarking {
-- Single-post templates must include data-pagefind-body attribute
-- on the <article> element to scope indexing to post content only.
-- Pagefind ignores elements without this attribute.
}
invariant PagefindAssets {
-- Generated output includes Pagefind UI assets per language:
-- {prefix}/pagefind/pagefind-ui.css
-- {prefix}/pagefind/pagefind-ui.js
-- where prefix is "" for main language, "{lang}" for additional languages.
-- Frontend templates reference these via language_prefix variable.
-- Assets are bundled locally — no external CDN references.
}
config {
pagefind_threshold: Integer = 0
-- Minimum pages to trigger indexing (0 = always if any rendered/deleted)
}

166
specs/git.allium Normal file
View File

@@ -0,0 +1,166 @@
-- allium: 1
-- bDS Git Integration
-- Scope: extension (Bucket A — Git + Validation)
-- Distilled from: src/main/engine/GitEngine.ts
use "./post.allium" as post
use "./script.allium" as script
use "./template.allium" as template
value GitProvider {
kind: github | gitlab | gitea_forgejo
-- Detected from remote URL patterns
}
value GitSyncStatus {
-- Per-commit: local_only | remote_only | both
kind: local_only | remote_only | both
}
surface GitSyncStatusSurface {
context status: GitSyncStatus
exposes:
status.kind
}
entity GitRepository {
is_initialized: Boolean
remote_url: String?
provider: GitProvider?
current_branch: String?
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
provides:
InitializeRepoRequested(project)
GitStatusRequested(project)
GitDiffRequested(project)
GitHistoryRequested(project, branch)
GitFetchRequested(project)
GitPullRequested(project)
GitPushRequested(project)
GitCommitAllRequested(project, message)
GitReconcileRequested(project, old_commit, new_commit)
PruneLfsCacheRequested(project, retain_recent)
}
rule InitializeRepo {
when: InitializeRepoRequested(project)
ensures: GitRepository.created(
is_initialized: true,
remote_url: null,
provider: null,
current_branch: "master",
has_lfs: true
)
ensures: GitignoreCreated(project)
-- .gitignore manages generated artifacts, cached assets, dependency directories, etc.
ensures: LfsTrackingConfigured(project)
-- Git LFS auto-tracks image patterns (*.jpg, *.png, *.gif, etc.)
}
rule GetStatus {
when: GitStatusRequested(project)
-- Returns file-level status: added, modified, deleted, renamed, untracked
ensures: GitStatusReport(files)
}
rule GetDiff {
when: GitDiffRequested(project)
ensures: GitDiffReport(staged_diff, unstaged_diff)
}
rule GetHistory {
when: GitHistoryRequested(project, branch)
-- Returns commit history with sync status per commit
ensures: GitHistoryReport(commits)
@guidance
-- Each commit annotated with: local_only, remote_only, or both
-- This drives the "push needed" / "pull needed" indicators
}
rule Fetch {
when: GitFetchRequested(project)
ensures: RemoteRefsUpdated(project)
}
rule Pull {
when: GitPullRequested(project)
ensures: LocalBranchUpdated(project)
ensures: GitReconcileRequested(project, previous_head(project), current_head(project))
-- After pull, detect changed files and reconcile DB
}
rule Push {
when: GitPushRequested(project)
ensures: RemoteBranchUpdated(project)
}
rule CommitAll {
when: GitCommitAllRequested(project, message)
ensures: AllChangesStaged(project)
ensures: CommitCreated(project, message)
}
-- Git reconciliation: sync DB from filesystem changes
rule ReconcileFromGit {
when: GitReconcileRequested(project, old_commit, new_commit)
-- Detect changed files between commits for posts, scripts, templates
let post_changes = changed_post_files(old_commit, new_commit)
let script_changes = changed_script_files(old_commit, new_commit)
let template_changes = changed_template_files(old_commit, new_commit)
for added in post_changes.added:
ensures: post/Post.created(parse_post_file(added))
for modified in post_changes.modified:
ensures: PostUpdatedFromFile(modified)
for deleted in post_changes.deleted:
ensures: PostDeletedByPath(deleted)
for renamed in post_changes.renamed:
ensures: PostFileRenamed(renamed.old, renamed.new)
-- Same pattern for scripts and templates
for added in script_changes.added:
ensures: script/Script.created(parse_script_file(added))
for added in template_changes.added:
ensures: template/Template.created(parse_template_file(added))
ensures: EntityChangedEventsEmitted(project)
}
invariant NonInteractiveGit {
-- All git operations run non-interactively:
-- GIT_TERMINAL_PROMPT=0
-- GCM_INTERACTIVE=never
-- ssh -oBatchMode=yes
-- No password prompts ever surface to the user
}
invariant StructuredAuthErrors {
-- Auth failures produce structured guidance:
-- per platform (macOS/Windows/Linux)
-- per provider (GitHub/GitLab/Gitea)
-- Instead of raw git error messages
}
rule PruneLfsCache {
when: PruneLfsCacheRequested(project, retain_recent)
-- Prunes LFS cache with configurable recent commit retention
ensures: LfsCachePruned(project)
}

54
specs/i18n.allium Normal file
View File

@@ -0,0 +1,54 @@
-- allium: 1
-- bDS Internationalization
-- Scope: core (Wave 0 onward — split localization is mandatory)
-- Distilled from: src/main/shared/i18n.ts, i18n/locales/*.json
value SupportedLanguage {
code: String
-- en, de, fr, it, es
flag: String
-- en=GB, de=DE, fr=FR, it=IT, es=ES
}
config {
supported_languages: Set<SupportedLanguage> = {
SupportedLanguage(code: "en", flag: "GB"),
SupportedLanguage(code: "de", flag: "DE"),
SupportedLanguage(code: "fr", flag: "FR"),
SupportedLanguage(code: "it", flag: "IT"),
SupportedLanguage(code: "es", flag: "ES")
}
default_language: String = "en"
}
invariant SplitLocalization {
-- Two independent locale scopes:
-- 1. UI locale: follows OS system locale
-- 2. Content/render locale: follows project settings (mainLanguage)
-- These are resolved independently and may differ
}
invariant LanguageNormalization {
-- Input language codes are normalized:
-- Take base language code (split on '-'): "en-US" -> "en"
-- Fall back to "en" if unrecognized
}
invariant MenuTranslations {
-- Menu item labels are separately translatable
-- translateMenu() uses UI locale (system/OS locale), NOT render locale
-- This follows the OS convention: menus match the system language
}
invariant RenderTranslations {
-- Template rendering i18n strings (date formats, archive labels,
-- "older posts", "newer posts", etc.) come from locale JSON files
-- translateRender() and getRenderTranslations() provide these
}
-- Stemmer language support for search (broader than UI languages)
invariant SnowballStemmerCoverage {
-- 24 languages supported for FTS5 search stemming
-- ISO 639-1 mapped to Snowball stemmer names
-- All 5 UI languages are a subset of stemmer languages
}

291
specs/layout.allium Normal file
View File

@@ -0,0 +1,291 @@
-- allium: 1
-- bDS Application Layout
-- Scope: UI shell (all waves)
-- Distilled from: src/renderer/App.tsx, ActivityBar.tsx, StatusBar.tsx,
-- Panel.tsx, WindowTitleBar.tsx, ResizablePanel, Sidebar.tsx
-- The top-level visual structure of the application window.
-- Describes the shell (regions, toggle behaviour, resize constraints)
-- but NOT the content of each region (see tabs.allium, sidebar_views.allium).
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 ─────────────────────────────────────────────
-- +------------------------------------------------------------+
-- | WindowTitleBar |
-- +----+--------+----------------------------+-----------------+
-- | A | Side- | TabBar | Assistant |
-- | c | bar |----------------------------| Sidebar |
-- | t | (resz) | Editor (routed by tab) | |
-- | i | |----------------------------+ |
-- | v | | Panel (bottom) | |
-- | i | | | |
-- | t | | | |
-- | y | | | |
-- +----+--------+----------------------------+-----------------+
-- | StatusBar |
-- +------------------------------------------------------------+
value AppShell {
title_bar: WindowTitleBar
activity_bar: ActivityBar
sidebar: ResizableRegion
content_area: ContentArea
assistant_sidebar: ResizableRegion
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
panel: Panel
}
-- ─── Resizable regions ────────────────────────────────────────
config {
sidebar_initial_width: Integer = 280
sidebar_min_width: Integer = 200
sidebar_max_width: Integer = 500
assistant_initial_width: Integer = 360
assistant_min_width: Integer = 280
assistant_max_width: Integer = 640
}
value ResizableRegion {
visible: Boolean
width: Integer
min_width: Integer
max_width: Integer
}
-- ─── Toggle state ─────────────────────────────────────────────
value ShellVisibility {
sidebar_visible: Boolean
panel_visible: Boolean
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
}
rule TogglePanel {
when: TogglePanelRequested()
ensures: panel_visible = not panel_visible
}
rule ToggleAssistantSidebar {
when: ToggleAssistantSidebarRequested()
ensures: assistant_sidebar_visible = not assistant_sidebar_visible
}
-- ─── Window title bar ─────────────────────────────────────────
value WindowTitleBar {
-- Platform-adaptive
-- menu_bar: rendered only on non-Mac platforms (6 groups: App, File, Edit, View, Window, Help)
-- macOS: native menu bar (same 6 groups)
-- Keyboard: Alt opens mnemonics, Alt+letter opens group
-- Arrow keys navigate groups and items, Enter/Space activates
-- View group hides devTools toggle when not in dev mode
title: String -- document.title, fallback "Blogging Desktop Server"
-- Three toggle buttons (all platforms): sidebar, panel, assistant
}
-- ─── Activity bar ─────────────────────────────────────────────
-- Narrow vertical icon strip at the far-left edge of the window.
-- Two groups: top (content views) and bottom (tools).
value ActivityBar {
top_group: List<ActivityButton>
bottom_group: List<ActivityButton>
}
value ActivityButton {
id: String -- matches SidebarView name
label_key: String -- i18n key for tooltip
badge: Badge? -- only git has a badge
active: Boolean -- highlighted when this view is showing
}
value Badge {
count: Integer
display: String -- count capped at "99+"
}
-- Exhaustive activity list with preserved order
-- Top group (content views):
-- 1. posts i18n:activity.posts
-- 2. pages i18n:activity.pages
-- 3. media i18n:activity.media
-- 4. scripts i18n:activity.scripts
-- 5. templates i18n:activity.templates
-- 6. tags i18n:activity.tags
-- 7. chat i18n:activity.aiAssistant
-- 8. import i18n:activity.import
-- Bottom group (tools):
-- 9. git i18n:activity.sourceControl (badge: pending pull count)
-- 10. settings i18n:common.settings
-- Each activity ID maps 1:1 to a SidebarView of the same name.
-- ─── Activity click behaviour ─────────────────────────────────
-- All activities share the same toggle-sidebar strategy.
-- The sidebar shows the view that matches the clicked activity.
rule ActivityClick {
when: ActivityClicked(activity_id)
let target_view = activity_id
if active_view = target_view:
ensures: ToggleSidebarRequested()
-- If already on this view, toggle sidebar open/closed
else:
ensures: active_view = target_view
if not sidebar_visible:
ensures: ToggleSidebarRequested()
-- Switch view; open sidebar if hidden
}
invariant ActivityActiveHighlight {
-- An activity button shows active state iff its view is the
-- current active_view AND the sidebar is visible
for btn in ActivityBar.all_buttons:
btn.active = (active_view = btn.id and sidebar_visible)
}
-- ─── Git badge ────────────────────────────────────────────────
-- Only the git activity button carries a badge.
-- Badge shows remote "behind" count, polled every 30 seconds.
config {
git_badge_poll_interval: Integer = 30
-- seconds between badge refresh polls
}
rule RefreshGitBadge {
when: GitBadgePollTick(badge)
requires: online and active_project != null
let repo_state = git.getRepoState()
requires: repo_state.is_repo and repo_state.has_remote
ensures: git.fetch()
let remote_state = git.getRemoteState()
ensures: badge.count = max(0, remote_state.behind)
}
rule ClearGitBadge {
when: ClearGitBadgeTick(badge)
requires: not online or active_project = null or not is_repo or not has_remote
ensures: badge.count = 0
}
-- ─── Bottom panel ─────────────────────────────────────────────
value Panel {
visible: Boolean
active_tab: String -- tasks | output | post_links | git_log
}
-- Panel tab availability depends on active editor tab
invariant PanelTabAvailability {
-- tasks: always available
-- output: always available
-- post_links: only when active editor tab is a post
-- git_log: only when active editor tab is a post or media
}
invariant PanelTabFallback {
-- If active panel tab becomes unavailable, fall back to tasks
-- post_links unavailable when no post tab is active
-- git_log unavailable when neither post nor media tab is active
}
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
-- Tasks with shared group_id are collapsible groups showing aggregate progress.
-- Output tab: log entries with copy-all button.
-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here).
-- Each entry clickable, opens linked post as pinned tab.
-- Git Log tab: file-level git history for active post/media (up to 50 entries).
-- For posts: path = posts/YYYY/MM/{slug}.md
-- For media: path relative to project root
-- ─── Status bar ───────────────────────────────────────────────
value StatusBar {
left: StatusBarLeft
right: StatusBarRight
}
value StatusBarLeft {
-- Project selector dropdown to switch active project
running_task_message: String? -- spinner + message when tasks running
running_task_overflow: Integer? -- "+N more" count when multiple running
}
value StatusBarRight {
-- In display order (left to right):
post_status: String? -- draft|published|archived dot, when post tab active
post_count: String -- "{count} posts"
media_count: String -- "{count} media"
token_usage: TokenUsage? -- shown only when active tab is chat
theme_badge: String -- pico theme name
offline_mode: Boolean -- airplane icon toggle, keyboard accessible
ui_language: String -- dropdown: en, de, fr, it, es
brand: String -- "bDS"
}
value TokenUsage {
input_tokens: Integer
output_tokens: Integer
cache_read_tokens: Integer
}
-- ─── Keyboard shortcuts (global) ──────────────────────────────
-- Ctrl/Cmd+B: toggle sidebar
-- Ctrl/Cmd+W: close active tab (see tabs.allium)

392
specs/mcp.allium Normal file
View File

@@ -0,0 +1,392 @@
-- allium: 1
-- bDS MCP Server (Model Context Protocol)
-- Scope: extension (Bucket G — MCP + Automation)
-- Distilled from: src/main/engine/MCPServer.ts, ProposalStore, MCPAgentConfigEngine.ts
use "./post.allium" as post
use "./media.allium" as media
use "./script.allium" as script
use "./template.allium" as template
entity McpServer {
transport: http | stdio
host: String -- 127.0.0.1 for HTTP
port: Integer -- 4124 for HTTP
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
entity_id: String
data: String
created_at: Timestamp
expires_at: Timestamp
draft_post: post/Post?
proposed_script: script/Script?
proposed_template: template/Template?
target_media: media/Media?
target_post: post/Post?
-- Derived
is_expired: expires_at <= now
transitions status {
pending -> accepted
pending -> discarded
pending -> expired
}
}
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
proposal_ttl_cli: Duration = 8.hours
}
surface McpAutomationSurface {
facing _: McpClient
provides:
McpToolInvoked("check_term", term)
McpToolInvoked("search_posts", params)
McpToolInvoked("count_posts", params)
McpToolInvoked("read_post_by_slug", slug, language)
McpToolInvoked("draft_post", params)
McpToolInvoked("propose_script", params)
McpToolInvoked("propose_template", params)
McpToolInvoked("propose_media_metadata", params)
McpToolInvoked("propose_post_metadata", params)
AcceptProposalRequested(proposal)
DiscardProposalRequested(proposal)
InstallAgentConfigRequested(agent_kind)
UninstallAgentConfigRequested(agent_kind)
}
invariant LocalhostOnlyHttp {
-- HTTP transport binds to 127.0.0.1 only
-- Origin validation: localhost only
-- CORS headers present
}
invariant StatelessHttpHandling {
-- Each HTTP request creates a fresh McpServer instance
-- No session state between requests
}
-- Read-only resources (bds:// scheme)
surface PostsResource {
facing viewer: McpClient
context posts: Posts
exposes:
for p in posts:
p.id
p.title
p.slug
p.status
p.tags
p.categories
p.created_at
p.backlinks
p.outlinks
@guidance
-- Paginated: 50 per page, base64url cursor
-- bds://posts, bds://posts?cursor={cursor}
}
surface MediaResource {
facing viewer: McpClient
context media_items: Media
exposes:
for m in media_items:
m.id
m.filename
m.title
m.alt
m.caption
m.tags
@guidance
-- bds://media, bds://media?cursor={cursor}
}
surface TagsResource {
facing viewer: McpClient
context tags: Tags
exposes:
for t in tags:
t.name
t.color
t.post_count
@guidance
-- bds://tags
}
surface CategoriesResource {
facing viewer: McpClient
context categories: Categories
exposes:
for c in categories:
c.name
c.post_count
@guidance
-- bds://categories
}
-- Read-only tools
rule CheckTerm {
when: McpToolInvoked("check_term", term)
-- Disambiguates a term as category, tag, or both
-- Returns post counts for each
let is_category = is_category_term(term)
let is_tag = is_tag_term(term)
ensures: TermCheckResult(
is_category: is_category,
category_post_count: if is_category: category_post_count(term) else: 0,
is_tag: is_tag,
tag_post_count: if is_tag: tag_post_count(term) else: 0
)
}
rule SearchPosts {
when: McpToolInvoked("search_posts", params)
-- Full-text + filtered search with pagination envelope
-- Params: query, category, tags[], language, missingTranslationLanguage,
-- year, month, status, offset, limit
-- Returns: { total, offset, limit, hasMore, posts[] }
-- Each post includes backlinks[] and linksTo[]
ensures: SearchEnvelope(results)
}
rule CountPosts {
when: McpToolInvoked("count_posts", params)
-- Grouped counts by: year, month, tag, category, status
-- Params: groupBy[], optional filters
ensures: GroupedCounts(results)
}
rule ReadPostBySlug {
when: McpToolInvoked("read_post_by_slug", slug, language)
-- Full post content by slug
-- Optional language parameter for translation view
ensures: FullPostContent(post)
}
-- Write tools (proposal-based)
rule DraftPost {
when: McpToolInvoked("draft_post", params)
-- Creates a draft post in DB
-- Returns proposalId for accept/discard lifecycle
ensures:
let new_post = post/Post.created(
title: params.title,
content: params.content,
status: draft
)
let proposal = Proposal.created(
kind: draft_post,
entity_id: new_post.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: new_post,
proposed_script: null,
proposed_template: null,
target_media: null,
target_post: null,
status: pending
)
proposal.status = pending
}
rule ProposeScript {
when: McpToolInvoked("propose_script", params)
requires: ValidateScript(params.content) = valid
ensures:
let new_script = script/Script.created(
title: params.title,
kind: params.kind,
content: params.content,
status: draft
)
let proposal = Proposal.created(
kind: propose_script,
entity_id: new_script.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: new_script,
proposed_template: null,
target_media: null,
target_post: null,
status: pending
)
proposal.status = pending
}
rule ProposeTemplate {
when: McpToolInvoked("propose_template", params)
requires: ValidateLiquid(params.content) = valid
ensures:
let new_template = template/Template.created(
title: params.title,
kind: params.kind,
content: params.content,
status: draft
)
let proposal = Proposal.created(
kind: propose_template,
entity_id: new_template.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: null,
proposed_template: new_template,
target_media: null,
target_post: null,
status: pending
)
proposal.status = pending
}
rule ProposeMediaMetadata {
when: McpToolInvoked("propose_media_metadata", params)
ensures:
let proposal = Proposal.created(
kind: propose_media_metadata,
entity_id: params.media_id,
data: serialize(params),
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: null,
proposed_template: null,
target_media: params.media,
target_post: null,
status: pending
)
proposal.status = pending
}
rule ProposePostMetadata {
when: McpToolInvoked("propose_post_metadata", params)
ensures:
let proposal = Proposal.created(
kind: propose_post_metadata,
entity_id: params.post_id,
data: serialize(params),
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: null,
proposed_template: null,
target_media: null,
target_post: params.post,
status: pending
)
proposal.status = pending
}
-- Proposal lifecycle
rule AcceptProposal {
when: AcceptProposalRequested(proposal)
requires: not proposal.is_expired
ensures:
if proposal.kind = draft_post:
post/PublishPostRequested(proposal.draft_post)
if proposal.kind = propose_script:
script/PublishScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template:
template/PublishTemplateRequested(proposal.proposed_template)
if proposal.kind = propose_media_metadata:
media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
if proposal.kind = propose_post_metadata:
post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
proposal.status = accepted
not exists proposal
}
rule DiscardProposal {
when: DiscardProposalRequested(proposal)
ensures:
if proposal.kind = draft_post:
post/DeletePostRequested(proposal.draft_post)
if proposal.kind = propose_script:
script/DeleteScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template:
template/DeleteTemplateRequested(proposal.proposed_template)
proposal.status = discarded
not exists proposal
}
rule ExpireProposal {
when: proposal: Proposal.is_expired becomes true
-- On expiry: clean up draft DB rows
ensures: proposal.status = expired
ensures: DiscardProposalRequested(proposal)
}
-- Agent configuration
value McpAgentKind {
-- Supported: claude_code, claude_desktop, github_copilot,
-- gemini_cli, opencode, mistral_vibe, openai_codex
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
ensures: AgentConfigInstalled(agent_kind)
}
rule UninstallAgentConfig {
when: UninstallAgentConfigRequested(agent_kind)
ensures: AgentConfigRemoved(agent_kind)
}
invariant ProposalPayloadEncoding {
-- Proposal.data stores a serialized payload for metadata proposals.
-- draft_post / propose_script / propose_template proposals keep the
-- created entity reference directly on the proposal record.
}

198
specs/media.allium Normal file
View File

@@ -0,0 +1,198 @@
-- allium: 1
-- bDS Media Lifecycle
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/MediaEngine.ts, schema.ts
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)
large: String -- 800px width (binary path)
ai: String -- 448x448 JPEG for vision models (binary path)
}
value SidecarFile {
-- {media_file}.meta (YAML-like key-value format)
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
-- Translations: {media_file}.{lang}.meta
path: String
}
surface SidecarFileSurface {
context sidecar: SidecarFile
exposes:
sidecar.path
}
entity Media {
project: project/Project
filename: String
original_name: String
mime_type: String
size: Integer
width: Integer?
height: Integer?
title: String?
alt: String?
caption: String?
author: String?
language: String?
file_path: String
sidecar_path: String
checksum: String?
tags: List<String>
created_at: Timestamp
updated_at: Timestamp
-- Relationships
translations: MediaTranslation with media = this
linked_posts: PostMediaLink with media_id = this.id
-- Derived
available_languages: translations -> language
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
title: String?
alt: String?
caption: String?
}
invariant UniqueMediaTranslation {
for a in MediaTranslations:
for b in MediaTranslations:
(a != b and a.media = b.media) implies a.language != b.language
}
invariant DateBasedMediaLayout {
for m in Media:
m.file_path = format("media/{yyyy}/{mm}/{uuid}.{ext}",
yyyy: m.created_at.year,
mm: m.created_at.month_padded,
uuid: stem(m.filename),
ext: extension(m.filename))
}
rule ImportMedia {
when: ImportMediaRequested(project, source_file)
let uuid_name = generate_uuid() + extension(source_file)
let dest = format("media/{yyyy}/{mm}/{uuid_name}",
yyyy: now.year, mm: now.month_padded)
ensures: Media.created(
project: project,
filename: uuid_name,
original_name: source_file.name,
mime_type: detect_mime(source_file),
size: source_file.size,
width: detect_width(source_file),
height: detect_height(source_file),
file_path: dest,
tags: {}
)
ensures: FileCopied(source_file, dest)
ensures: SidecarWritten(media)
ensures: ThumbnailsGenerated(media)
ensures: SearchIndexUpdated(media)
}
rule UpdateMedia {
when: UpdateMediaRequested(media, changes)
ensures: MediaFieldsUpdated(media, changes)
ensures: media.updated_at = now
ensures: SidecarWritten(media)
-- Metadata changes flush to .meta sidecar
ensures: SearchIndexUpdated(media)
}
rule DeleteMedia {
when: DeleteMediaRequested(media)
ensures: not exists media
ensures: MediaFileDeleted(media)
ensures: SidecarDeleted(media)
ensures: ThumbnailsDeleted(media)
ensures:
for t in media.translations:
not exists t
ensures: SearchIndexUpdated(media)
}
rule UpsertMediaTranslation {
when: UpsertMediaTranslationRequested(media, language, title, alt, caption)
ensures: MediaTranslation.created(
media: media,
language: language,
title: title,
alt: alt,
caption: caption
)
ensures: TranslationSidecarWritten(media, language)
-- Writes {file}.{lang}.meta
}
rule RebuildMediaFromFiles {
when: RebuildMediaFromFilesRequested(project)
-- Scans media directory for .meta sidecars, reimports to DB
for sidecar in scan_directory(project.effective_data_dir + "/media", "*.meta"):
let parsed = parse_sidecar(sidecar)
ensures: Media.created(parsed)
-- or updated if already exists
@guidance
-- This is the filesystem-to-DB reconciliation path
-- Used after git pull or manual file changes
}
invariant SidecarRoundtrip {
-- Sidecar files faithfully represent DB metadata
for m in Media:
parse_sidecar(m.sidecar_path).title = m.title
parse_sidecar(m.sidecar_path).alt = m.alt
parse_sidecar(m.sidecar_path).caption = m.caption
parse_sidecar(m.sidecar_path).tags = m.tags
}

View File

@@ -0,0 +1,374 @@
-- allium: 1
-- bDS Media Processing Specification
-- Scope: core (Wave 1 — media import and processing)
-- Distilled from: ../bDS/src/main/engine/MediaEngine.ts,
-- mediaProcessing.ts, thumbnail generation logic
--
-- This document specifies the exact media processing behavior:
-- thumbnail generation, format conversion, EXIF handling, and file organization.
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
-- ============================================================================
value MediaFileLayout {
-- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext}
-- Sidecar metadata in: {binary_path}.meta
-- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp
-- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg)
binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext}
sidecar_path: String -- {binary_path}.meta
thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp
thumbnail_medium: String -- thumbnails/{prefix}/{id}-medium.webp
thumbnail_large: String -- thumbnails/{prefix}/{id}-large.webp
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}
-- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg
for m in Media:
m.filename = format("{uuid}.{ext}",
uuid: generate_uuid_v4(),
ext: file_extension(m.original_name))
}
invariant ThumbnailPathBucketing {
-- Thumbnails are bucketed by first 2 chars of media ID
-- This avoids filesystem slowdowns from too many files in one directory
for m in Media:
let prefix = substring(m.id, 0, 2)
m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp",
prefix: prefix, id: m.id)
m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp",
prefix: prefix, id: m.id)
m.thumbnails.large = format("thumbnails/{prefix}/{id}-large.webp",
prefix: prefix, id: m.id)
m.thumbnails.ai = format("thumbnails/{prefix}/{id}-ai.jpg",
prefix: prefix, id: m.id)
}
-- ============================================================================
-- THUMBNAIL GENERATION
-- ============================================================================
config {
-- Four thumbnail sizes generated per image
thumbnail_small_width: Integer = 150
thumbnail_medium_width: Integer = 400
thumbnail_large_width: Integer = 800
thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG
thumbnail_format: String = "webp" -- All sizes except AI (encoder default quality)
thumbnail_ai_format: String = "jpeg" -- AI thumbnail only
}
rule GenerateThumbnails {
when: MediaImported(media)
requires: is_image(media.mime_type)
-- Generate all four thumbnail sizes
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.small,
width: config.thumbnail_small_width,
format: config.thumbnail_format
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.medium,
width: config.thumbnail_medium_width,
format: config.thumbnail_format
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.large,
width: config.thumbnail_large_width,
format: config.thumbnail_format
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.ai,
size: config.thumbnail_ai_size,
format: config.thumbnail_ai_format
)
}
-- Thumbnail generation algorithm
value ThumbnailGeneration {
-- 1. Load source image
-- 2. Apply EXIF orientation correction (rotation, flip) so thumbnails display correctly
-- 3. Resize: small/medium/large preserve aspect ratio (width-constrained)
-- AI thumbnail is a 448x448 center crop (letterboxed on black background)
-- 4. Encode as WebP (encoder default quality) for small/medium/large
-- Encode as JPEG for AI thumbnail
-- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext}
--
-- 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.
-- Width/height stored in DB are the raw header values (pre-rotation).
}
-- ============================================================================
-- IMAGE PROCESSING RULES
-- ============================================================================
value ImageProcessing {
-- Supported input formats:
input_formats: Set<String> = {
"image/jpeg", "image/png", "image/gif",
"image/webp", "image/tiff", "image/bmp",
"image/heic", "image/heif"
}
-- Output formats:
output_formats: Set<String> = {
"image/webp", -- Primary output for thumbnails
"image/jpeg" -- AI thumbnail only
}
-- Processing rules:
-- 1. All thumbnails (except AI) are encoded as WebP (encoder default quality)
-- 2. AI thumbnail is encoded as JPEG (for vision model compatibility)
-- 3. Original format is preserved for full-size assets (no conversion)
-- 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
ensures: media.width = extract_width_from_header(source_file)
ensures: media.height = extract_height_from_header(source_file)
ensures: media.mime_type = detect_mime_from_extension(source_file)
ensures: media.size = file_size(source_file)
}
invariant MimeDetection {
-- MIME type is detected from file extension, not from file content/magic bytes
-- Extension mapping: .jpg/.jpeg -> image/jpeg, .png -> image/png, etc.
}
-- ============================================================================
-- MEDIA TRANSLATION FILES
-- ============================================================================
value MediaTranslationFile {
-- File path: {binary_path}.{language}.meta
-- Format: YAML-like key-value sidecar (same as canonical sidecar)
translation_for: String -- Canonical media ID
language: String -- ISO 639-1 code
title: String?
alt: String?
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
t.file_path = format("{binary_path}.{lang}.meta",
binary_path: t.media.file_path,
lang: t.language)
}
-- ============================================================================
-- MEDIA IMPORT RULES
-- ============================================================================
rule ImportMedia {
when: ImportMediaRequested(source_path, project)
-- 1. Validate file type (must be supported image)
-- 2. Generate UUID v4 filename
-- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext}
-- 4. Write sidecar {binary_path}.meta
-- 5. Generate four thumbnail sizes
-- 6. Index for search (FTS5)
ensures: media/Media.created(
filename: generate_uuid_v4_filename(source_path),
original_name: basename(source_path),
mime_type: detect_mime_from_extension(source_path),
size: file_size(source_path),
width: extract_width_from_header(source_path),
height: extract_height_from_header(source_path),
file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"),
sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"),
checksum: sha256(source_path)
)
ensures: ThumbnailsGenerated(media_id)
ensures: SearchIndexUpdated(media_id)
}
-- ============================================================================
-- MEDIA TAGGING
-- ============================================================================
invariant MediaTagsFormat {
-- Media tags are stored as JSON array in sidecar file
-- Tags are optional and only written if present
-- Same format as post tags
}
rule TagMedia {
when: TagMediaRequested(media, tags)
ensures: media.tags = tags
ensures: SidecarFileUpdated(media)
ensures: SearchIndexUpdated(media)
}
-- ============================================================================
-- MEDIA DELETION
-- ============================================================================
rule DeleteMedia {
when: DeleteMediaRequested(media)
-- 1. Remove from database
-- 2. Delete binary file
-- 3. Delete sidecar file ({binary_path}.meta)
-- 4. Delete all four thumbnail files
-- 5. Delete translation sidecars ({binary_path}.{lang}.meta)
-- 6. Remove from search index
-- 7. Remove from all post links
ensures: FileDeleted(media.file_path)
ensures: FileDeleted(media.sidecar_path)
ensures: FileDeleted(media.thumbnails.small)
ensures: FileDeleted(media.thumbnails.medium)
ensures: FileDeleted(media.thumbnails.large)
ensures: FileDeleted(media.thumbnails.ai)
ensures: SearchIndexRemoved(media)
ensures:
for p in media.linked_posts:
p.linked_media = p.linked_media - {media}
}
-- ============================================================================
-- MEDIA VALIDATION
-- ============================================================================
rule ValidateMedia {
when: ValidateMediaRequested(project)
-- Check for:
-- 1. Missing binary files
-- 2. Missing sidecar files
-- 3. Missing thumbnails (all 4 sizes)
-- 4. Corrupted image files
-- 5. Orphan media (not linked to any post)
for m in project.media:
if not file_exists(m.file_path):
ensures: ValidationIssueReported(m, "missing_binary")
if not file_exists(m.sidecar_path):
ensures: ValidationIssueReported(m, "missing_sidecar")
if not file_exists(m.thumbnails.small):
ensures: ValidationIssueReported(m, "missing_thumbnail_small")
if not file_exists(m.thumbnails.medium):
ensures: ValidationIssueReported(m, "missing_thumbnail_medium")
if not file_exists(m.thumbnails.large):
ensures: ValidationIssueReported(m, "missing_thumbnail_large")
if not file_exists(m.thumbnails.ai):
ensures: ValidationIssueReported(m, "missing_thumbnail_ai")
if not is_valid_image(m.file_path):
ensures: ValidationIssueReported(m, "corrupted")
if not exists (p in Posts where m in p.linked_media):
ensures: ValidationIssueReported(m, "orphan")
}
-- ============================================================================
-- MEDIA SIDECAR FORMAT
-- ============================================================================
invariant MediaSidecarFormat {
-- Sidecar files use YAML-like key-value format (hand-built, not gray-matter)
-- Path: {binary_path}.meta
-- Only truthy fields are written (except required fields)
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
-- Note: 'filename' is NOT written to sidecar (it is the binary filename itself)
}
-- ============================================================================
-- IMAGE OPTIMIZATION
-- ============================================================================
config {
-- No file size limit on import
-- Original files are stored as-is (no compression, no resize)
-- Only thumbnails are generated from the original
strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it
}
-- ============================================================================
-- MEDIA SEARCH INDEXING
-- ============================================================================
rule IndexMediaForSearch {
when: SearchIndexUpdated(media: media/Media)
-- Index fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions
let all_text = concat(
media.title,
media.alt,
media.caption,
media.original_name,
join(media.tags, " ")
)
let stemmed = stem(all_text, detect_language(all_text))
ensures: search/MediaSearchIndex.created(
media: media,
stemmed_content: stemmed
)
}

56
specs/menu.allium Normal file
View File

@@ -0,0 +1,56 @@
-- allium: 1
-- bDS Navigation Menu
-- 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
slug: String?
children: List<MenuItem>? -- only for submenu kind
}
entity Menu {
items: List<MenuItem>
-- Derived
home_items: items where kind = home
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:
menu.items.first.kind = home
}
invariant MenuPersistedAsOpml {
-- meta/menu.opml is the canonical storage format
-- Uses OPML with outline elements for each item
parse_opml(read_file("meta/menu.opml")) = menu.items
}
rule UpdateMenu {
when: UpdateMenuRequested(menu, items)
-- Normalizes Home entry: extracts from items, prepends
let without_home = items where kind != home
let home = MenuItem{kind: home, label: "Home"}
ensures: menu.items = build_menu_items(home, without_home)
ensures: MenuFileWritten(menu)
}

125
specs/metadata.allium Normal file
View File

@@ -0,0 +1,125 @@
-- allium: 1
-- bDS Project Metadata, Categories, Publishing Preferences
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/MetaEngine.ts, schema.ts
use "./project.allium" as project
surface MetadataControlSurface {
facing _: MetadataOperator
provides:
UpdateProjectMetadataRequested(project, changes)
AddCategoryRequested(project, name)
RemoveCategoryRequested(project, name)
UpdateCategorySettingsRequested(project, category, settings)
SetPublishingPreferencesRequested(project, prefs)
AppStarted(project)
}
surface PublishingPreferencesSurface {
context prefs: PublishingPreferences
exposes:
prefs.ssh_host when prefs.ssh_host != null
prefs.ssh_user when prefs.ssh_user != null
prefs.ssh_remote_path when prefs.ssh_remote_path != null
prefs.ssh_mode
}
value CategoryRenderSettings {
render_in_lists: Boolean
show_title: Boolean
post_template_slug: String?
list_template_slug: String?
}
entity ProjectMetadata {
project: project/Project
name: String
description: String?
public_url: String?
main_language: String? -- ISO 639-1
default_author: String?
max_posts_per_page: Integer -- 1..500, default 50
blogmark_category: String?
pico_theme: String? -- 12+ named Pico CSS themes
semantic_similarity_enabled: Boolean
blog_languages: Set<String> -- subset of supported languages
categories: Set<String> -- category names
category_settings: Set<CategoryRenderSettings>
}
entity PublishingPreferences {
ssh_host: String?
ssh_user: String?
ssh_remote_path: String?
ssh_mode: scp | rsync
}
invariant DefaultCategories {
-- New projects start with: article, picture, aside, page
-- These are defaults, not invariants — user can remove them
}
invariant MetadataPersistedAsFiles {
-- Four separate JSON files in meta/:
-- meta/project.json — name, description, publicUrl, mainLanguage, etc.
-- meta/categories.json — sorted category list
-- meta/category-meta.json — per-category render settings
-- meta/publishing.json — SSH connection details (non-secret)
-- All writes are atomic (temp file + rename)
}
config {
default_max_posts_per_page: Integer = 50
min_posts_per_page: Integer = 1
max_posts_per_page: Integer = 500
default_categories: Set<String> = {"article", "picture", "aside", "page"}
supported_pico_themes: Set<String> = {
"default", "amber", "blue", "cyan", "fuchsia", "green",
"grey", "indigo", "jade", "lime", "orange", "pink",
"pumpkin", "purple", "red", "sand", "slate", "violet",
"yellow", "zinc"
}
}
rule UpdateProjectMetadata {
when: UpdateProjectMetadataRequested(project, changes)
ensures: MetadataFieldsUpdated(project, changes)
ensures: ProjectJsonWritten(project)
}
rule AddCategory {
when: AddCategoryRequested(project, name)
requires: not (name in project.metadata.categories)
ensures: project.metadata.categories = project.metadata.categories + {name}
ensures: CategoriesJsonWritten(project)
}
rule RemoveCategory {
when: RemoveCategoryRequested(project, name)
ensures: project.metadata.categories = project.metadata.categories - {name}
ensures: CategorySettingsRemoved(project, name)
ensures: CategoriesJsonWritten(project)
ensures: CategoryMetaJsonWritten(project)
}
rule UpdateCategorySettings {
when: UpdateCategorySettingsRequested(project, category, settings)
ensures: CategorySettingsUpdated(project, category, settings)
ensures: CategoryMetaJsonWritten(project)
}
rule SetPublishingPreferences {
when: SetPublishingPreferencesRequested(project, prefs)
ensures: project.publishing_preferences = prefs
ensures: PublishingJsonWritten(project)
}
rule StartupSync {
when: AppStarted(project)
-- Loads metadata from filesystem, merges with DB,
-- creates defaults for new projects
ensures: ProjectMetadata.synced_from_filesystem(project)
}

View File

@@ -0,0 +1,81 @@
-- allium: 1
-- bDS Metadata Diff and Rebuild
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/MetadataDiffEngine.ts
use "./post.allium" as post
use "./media.allium" as media
use "./script.allium" as script
use "./template.allium" as template
surface MetadataMaintenanceSurface {
facing _: MaintenanceOperator
provides:
MetadataDiffRequested(project)
RebuildFromFilesystemRequested(project, entity_type)
}
value DiffField {
name: String
db_value: String
file_value: String
}
value DiffReport {
entity_type: String -- post, media, script, template
entity_id: String
differences: List<DiffField>
}
value OrphanReport {
file_path: String
-- File exists on disk but has no DB record
}
rule RunMetadataDiff {
when: MetadataDiffRequested(project)
-- Runs as background task via TaskManager
-- Compares DB records against filesystem files for:
-- posts, translations, media, scripts, templates
-- Detected fields: tags, categories, title, excerpt, author,
-- language, status, templateSlug, dates
for post in project.posts:
let file_data = parse_post_file(post.file_path)
let diffs = compare_fields(post, file_data)
if diffs.count > 0:
ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs)
-- Detect orphan files (on disk but not in DB)
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"):
let matching = Posts where file_path = file
if matching.count = 0:
ensures: OrphanReport.created(file_path: file)
-- Same pattern for media sidecar files, scripts, templates
}
rule RebuildFromFilesystem {
when: RebuildFromFilesystemRequested(project, entity_type)
-- The inverse of metadata diff: filesystem is treated as truth
-- Reads all files and upserts into DB
ensures:
if entity_type = "post":
post/RebuildPostsFromFiles(project)
if entity_type = "media":
media/RebuildMediaFromFiles(project)
if entity_type = "script":
script/RebuildScriptsFromFiles(project)
if entity_type = "template":
template/RebuildTemplatesFromFiles(project)
}
invariant ThreeWaySync {
-- Metadata must stay in sync across three representations:
-- 1. Database records
-- 2. Filesystem files (frontmatter/sidecars)
-- 3. Generated site output
-- MetadataDiff detects divergence between (1) and (2)
-- Rebuild resolves divergence by treating (2) as truth
-- Site generation consumes (1) to produce (3)
}

251
specs/modals.allium Normal file
View File

@@ -0,0 +1,251 @@
-- allium: 1
-- bDS Shared Modals
-- Scope: UI overlays used across multiple editor views
-- Distilled from: PostEditor.tsx, MediaEditor.tsx
-- Shared modal components used by multiple editors.
-- Editor-specific modals live in their respective editor spec files.
use "./i18n.allium" as i18n
-- ─── AI Suggestions Modal ────────────────────────────────────
-- Shared modal for presenting AI suggestions with per-field accept/reject.
-- Used by PostAIAnalysis (editor_post.allium) and
-- MediaAIImageAnalysis (editor_media.allium).
value AISuggestionsModal {
fields: List<AISuggestionField>
-- Layout: title bar ("AI Suggestions"), scrollable field list, button row
-- Each field rendered as a row:
-- Left: checkbox (accept/reject), label
-- Center: current value (read-only, muted), arrow, suggested value (highlighted)
-- Special: slug field checkbox disabled if post was ever published
-- Buttons: Cancel (secondary), Apply Selected (primary)
-- Cancel discards all; Apply writes only accepted fields to entity
}
surface AISuggestionsModalSurface {
context modal: AISuggestionsModal
exposes:
modal.fields.count
}
value AISuggestionField {
label: String
current_value: String
suggested_value: String
accepted: Boolean -- checkbox, default true
locked: Boolean -- if true, checkbox disabled (e.g. published slug)
}
-- ─── Insert Post Link Modal ──────────────────────────────────
value InsertPostLinkModal {
-- Two-tab modal opened by Ctrl/Cmd+K in post editor (markdown mode).
-- Tab 1 - Internal:
-- Search input (debounced 300ms, queries post titles via FTS)
--
-- Empty query state (search_query < 2 chars):
-- If semanticSimilarityEnabled: shows up to 5 related posts via
-- FindSimilar(current_post, 5) ranked by embedding similarity
-- Else: shows nothing (empty results)
--
-- Active query state (search_query >= 2 chars):
-- Results from FTS title search
-- If semanticSimilarityEnabled: each result augmented with similarity
-- score from ComputeSimilarities(current_post, result_post_ids)
-- Scores displayed as visual indicator per result row
-- Results list: post title + status badge + optional similarity score
--
-- Click result: inserts [title](/YYYY/MM/DD/slug) at cursor, closes modal
-- "Create Post" row at bottom of results:
-- Creates new post with search query as title, inserts link to it
-- Tab 2 - External:
-- URL input field (required)
-- Display text input field (optional)
-- Insert button: inserts [text](url) or bare url if no text, closes modal
active_tab: String -- internal | external
search_query: String
results: List<InsertLinkResult>
related_posts: List<InsertLinkResult> -- 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
status: String -- draft | published | archived
canonical_url: String -- /YYYY/MM/DD/slug
similarity_score: Decimal? -- 0.0-1.0, present when embeddings enabled
}
-- ─── Insert Media Modal ──────────────────────────────────────
value InsertMediaModal {
-- Grid modal for inserting media references into post content.
-- Search input filtering by media title and original filename.
-- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below.
-- Click item:
-- Images: inserts ![alt](bds-media://id) at cursor
-- Non-images: inserts [originalName](bds-media://id) at cursor
-- Closes modal after insertion.
search_query: String
results: List<InsertMediaResult>
}
surface InsertMediaModalSurface {
context modal: InsertMediaModal
exposes:
modal.search_query
modal.results.count
}
value InsertMediaResult {
media_id: String
title: String
original_name: String
is_image: Boolean
thumbnail_url: String? -- bds-thumb:// for images, null for others
}
-- ─── Language Picker Modal ───────────────────────────────────
value LanguagePickerModal {
-- Shown for Translate Post and Translate Media Metadata actions.
-- Lists all configured blogLanguages except source language.
-- Each row: flag emoji, language name, status badge if translation exists.
-- Existing translations show "(draft)" or "(published)" badge.
-- Click selects target language and initiates translation flow.
-- Cancel closes without action.
source_language: String
available_targets: List<LanguageTarget>
}
surface LanguagePickerModalSurface {
context modal: LanguagePickerModal
exposes:
modal.source_language
modal.available_targets.count
}
value LanguageTarget {
code: String
name: String
flag_emoji: String
has_existing_translation: Boolean
existing_status: String? -- draft | published, if translation exists
}
-- ─── Confirm Delete Modal ────────────────────────────────────
value ConfirmDeleteModal {
-- Custom styled modal for destructive operations with reference info.
-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count).
-- Layout: warning icon, title, entity name, reference section, buttons.
-- Reference section: "This item is referenced by:" + bulleted list.
-- Buttons: Cancel (secondary), Delete (destructive red).
entity_name: String
entity_type: String -- media | tag
reference_count: Integer
reference_list: List<String> -- 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 {
-- Custom styled modal for non-delete confirmations.
-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.").
-- Layout: title, descriptive message, buttons.
-- Buttons: Cancel (secondary), Confirm (primary).
title: String
message: String
}
surface ConfirmDialogSurface {
context modal: ConfirmDialog
exposes:
modal.title
modal.message
}
-- System confirm dialogs are NOT modelled as values.
-- They are simple yes/no system dialogs with a message string.
-- Used by: PostDelete, PostDiscard, TemplateDelete (with references).
-- ─── Gallery Overlay ─────────────────────────────────────────
value GalleryOverlay {
-- Full-screen overlay showing all media linked to a post.
-- Opened from Gallery button in post editor toolbar (markdown mode).
-- Image grid: bds-thumb:// thumbnails (medium 400px), 3-4 columns.
-- Click image: opens LightboxView for that image.
-- Close: X button or ESC key.
post_id: String
images: List<GalleryImage>
}
surface GalleryOverlaySurface {
context overlay: GalleryOverlay
exposes:
overlay.post_id
overlay.images.count
}
value GalleryImage {
media_id: String
thumbnail_url: String -- bds-thumb://media_id
alt_text: String?
}
value LightboxView {
-- Full-screen image viewer, sub-view of GalleryOverlay.
-- Shows single image at full resolution via bds-media:// protocol.
-- Navigation: left/right arrow buttons, keyboard left/right arrow keys.
-- Close: X button, ESC key, or click outside image area.
-- Header: image title or filename, index counter "3 of 12".
current_index: Integer
total_count: Integer
media_id: String
image_url: String -- bds-media://media_id
alt_text: String?
}
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.

234
specs/post.allium Normal file
View File

@@ -0,0 +1,234 @@
-- allium: 1
-- bDS Post Lifecycle
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/PostEngine.ts, postFileUtils.ts, schema.ts
use "./project.allium" as project
value Slug {
value: String
-- Generated by: transliterate unicode to ASCII, lowercase,
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
-- Verify transliteration matches the established bDS behaviour for this set.
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}
}
value PostFilePath {
-- posts/YYYY/MM/{slug}.md
-- YYYY and MM derived from created_at
base_dir: String
year: String
month: String
slug: Slug
}
value PostCanonicalUrl {
-- /{YYYY}/{MM}/{DD}/{slug}
-- YYYY/MM/DD from created_at (zero-padded)
year: String
month: String
day: String
slug: Slug
}
value Frontmatter {
-- YAML between --- delimiters at start of .md file
-- Always present: id, title, slug, status, createdAt, updatedAt, tags, categories
-- Optional (written only when truthy): excerpt, author, language,
-- doNotTranslate (only when true), templateSlug, publishedAt
}
surface PostControlSurface {
facing _: PostOperator
provides:
CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
UpdatePostRequested(post, changes)
PublishPostRequested(post)
DeletePostRequested(post)
ArchivePostRequested(post)
}
surface PostFilePathSurface {
context path: PostFilePath
exposes:
path.base_dir
path.year
path.month
path.slug
}
surface PostCanonicalUrlSurface {
context url: PostCanonicalUrl
exposes:
url.year
url.month
url.day
url.slug
}
surface FrontmatterSurface {
context _: Frontmatter
}
entity Post {
project: project/Project
title: String
slug: Slug
excerpt: String?
content: String?
status: draft | published | archived
author: String?
language: String?
do_not_translate: Boolean
template_slug: String?
file_path: String
checksum: String?
tags: List<String>
categories: List<String>
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
-- Relationships
translations: PostTranslation with canonical_post = this
linked_media: PostMediaLink with post = this
outgoing_links: PostLink with source = this
incoming_links: PostLink with target = this
-- Derived
available_languages: translations -> language
is_slug_frozen: published_at != null
-- Slug changes only allowed before first publish
content_location: if status = published: file_path else: content
-- Published: body in filesystem. Draft: body in DB field.
transitions status {
draft -> published
draft -> archived
published -> draft
published -> archived
archived -> draft
archived -> published
}
}
entity PostLink {
source: Post
target: Post
link_text: String?
}
entity PostMediaLink {
post: Post
media_id: String
sort_order: Integer
}
invariant UniqueSlugPerProject {
for a in Posts:
for b in Posts:
(a != b and a.project = b.project) implies a.slug != b.slug
}
rule CreatePost {
when: CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
let slug = Slug.generate(title ?? "untitled")
let unique_slug = Slug.ensure_unique(slug, project)
ensures:
let new_post = Post.created(
project: project,
title: title ?? "",
slug: unique_slug,
content: content,
status: draft,
author: author,
language: language,
tags: tags ?? {},
categories: categories ?? {},
template_slug: template_slug,
do_not_translate: false,
file_path: ""
)
new_post.status = draft
SearchIndexUpdated(new_post)
}
rule UpdatePost {
when: UpdatePostRequested(post, changes)
requires: not post.is_slug_frozen or changes.slug = null
-- Cannot change slug after first publish
ensures: post.updated_at = now
ensures: PostFieldsUpdated(post, changes)
ensures: SearchIndexUpdated(post)
@guidance
-- If post is published and content/metadata changed,
-- status auto-transitions back to draft
}
rule ReopenPublishedPost {
when: UpdatePostRequested(post, changes)
requires: post.status = published
requires: changes_affect_published_content(changes)
ensures: post.status = draft
}
rule PublishPost {
when: PublishPostRequested(post)
requires: post.status = draft or post.status = archived
ensures: post.status = published
ensures: post.published_at = post.published_at ?? now
-- Preserve original publish date on re-publish
ensures: PostFileWritten(post)
-- Writes frontmatter + markdown to posts/YYYY/MM/{slug}.md
ensures: post.content = null
-- Content cleared from DB; now lives in filesystem only
ensures: SearchIndexUpdated(post)
ensures: PostLinksUpdated(post)
-- Parse inter-post links, update link graph
ensures:
for t in post.translations:
TranslationFileWritten(t)
}
rule DeletePost {
when: DeletePostRequested(post)
ensures: not exists post
ensures: PostFileDeleted(post)
-- Remove .md file if it exists
ensures:
for t in post.translations:
not exists t
ensures: SearchIndexUpdated(post)
}
rule ArchivePost {
when: ArchivePostRequested(post)
requires: post.status = draft or post.status = published
ensures: post.status = archived
}
-- File format axioms
invariant FrontmatterRoundtrip {
-- Reading a post file written by the system produces identical
-- field values to the database record at time of writing
for post in Posts where status = published:
parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post)
}
invariant DateBasedFileLayout {
for post in Posts where file_path != "":
post.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
yyyy: post.created_at.year,
mm: post.created_at.month_padded,
slug: post.slug)
}
-- Slug freeze: once published_at is set, the slug is permanently frozen.
-- This follows the established bDS rule: is_slug_frozen = published_at != null
-- Even if the post reverts to draft, the slug cannot be changed.

108
specs/preview.allium Normal file
View File

@@ -0,0 +1,108 @@
-- allium: 1
-- bDS Local Preview Server
-- Scope: core (Wave 4)
-- Distilled from: src/main/engine/PreviewServer.ts, PageRenderer.ts
use "./template.allium" as template
use "./generation.allium" as generation
entity PreviewServer {
host: String -- 127.0.0.1
port: Integer -- 4123
is_running: Boolean
}
config {
preview_host: String = "127.0.0.1"
preview_port: Integer = 4123
}
surface PreviewControlSurface {
facing _: PreviewOperator
provides:
StartPreviewRequested(project)
StopPreviewRequested(server)
}
surface PreviewHttpSurface {
facing _: PreviewClient
provides:
PreviewRequest(path)
PreviewDraftRequest(path, post_id)
}
rule StartPreview {
when: StartPreviewRequested(project)
ensures: PreviewServer.created(
host: config.preview_host,
port: config.preview_port,
is_running: true
)
}
rule StopPreview {
when: StopPreviewRequested(server)
-- Graceful shutdown with inflight request tracking
ensures: server.is_running = false
}
-- Route resolution
rule ServePostPreview {
when: PreviewRequest(path)
requires: is_post_path(path)
-- path matches "/{yyyy}/{mm}/{dd}/{slug}"
-- Renders post via Liquid template with full PageRenderer context
ensures: PreviewResponse(rendered_html)
}
rule ServeDraftPreview {
when: PreviewDraftRequest(path, post_id)
-- Renders draft content (from DB, not filesystem)
ensures: PreviewResponse(rendered_html)
}
rule ServeArchivePreview {
when: PreviewRequest(path)
requires: is_archive_path(path)
-- Category, tag, date archives with pagination
ensures: PreviewResponse(rendered_html)
}
rule ServeMediaFile {
when: PreviewRequest(path)
requires: is_media_path(path)
-- Path-traversal protection: validates path stays within media directory
ensures: PreviewResponse(media_file)
}
rule ServeAssets {
when: PreviewRequest(path)
requires: is_asset_path(path)
ensures: PreviewResponse(asset_file)
}
rule ServeLanguagePrefixedRoute {
when: PreviewRequest(path)
requires: is_language_prefixed(path)
-- Detects language prefix from supported languages
-- Renders with translation overlay for that language
ensures: PreviewResponse(translated_html)
}
invariant ThemeSwitching {
-- Preview supports live theme/mode switching via query params
-- ?theme=amber&mode=dark etc.
-- Uses Pico CSS with configurable themes
}
invariant PreviewServerBinding {
for server in PreviewServers where server.is_running:
server.host = config.preview_host and server.port = config.preview_port
}
invariant LocalhostOnly {
-- Preview server binds to 127.0.0.1 only, never 0.0.0.0
}

110
specs/project.allium Normal file
View File

@@ -0,0 +1,110 @@
-- allium: 1
-- bDS Project Management
-- 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
description: String?
data_path: String?
is_active: Boolean
created_at: Timestamp
updated_at: Timestamp
-- Relationships
posts: Post with project = this
media: Media with project = this
tags: Tag with project = this
-- Derived
internal_base_dir: String
-- {user_data}/projects/{id}/
-- Contains: meta/, thumbnails/, tags.json
effective_data_dir: data_path ?? internal_base_dir
-- 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
active.count = 1
}
invariant UniqueProjectSlug {
for a in Projects:
for b in Projects:
a != b implies a.slug != b.slug
}
rule CreateProject {
when: CreateProjectRequested(name, data_path)
let slug = slugify(name)
ensures: Project.created(
name: name,
slug: slug,
data_path: data_path,
is_active: false
)
ensures: StarterTemplatesCopied(project)
-- Bundled starter templates are copied into the new project
}
rule SetActiveProject {
when: SetActiveProjectRequested(project)
let previous = Projects where is_active = true
ensures:
for p in previous:
p.is_active = false
ensures: project.is_active = true
}
rule DeleteProject {
when: DeleteProjectRequested(project)
requires: project.id != "default"
-- The default project (id='default') cannot be deleted
requires: project.is_active = false
-- The currently active project cannot be deleted
ensures: not exists project
@guidance
-- deleteProjectWithData removes DB rows + internal directory
-- but preserves external data at custom data_path
}
config {
default_project_id: String = "default"
default_project_name: String = "My Blog"
}
invariant DefaultProjectExists {
-- A project with id='default' always exists
-- It is created on first launch if missing
exists p in Projects where p.id = "default"
}

140
specs/publishing.allium Normal file
View File

@@ -0,0 +1,140 @@
-- allium: 1
-- bDS SSH Publishing
-- Scope: core (Wave 5)
-- Distilled from: src/main/engine/PublishEngine.ts
use "./metadata.allium" as meta
entity PublishJob {
ssh_host: String
ssh_user: String
ssh_remote_path: String
ssh_mode: scp | rsync
status: pending | running | completed | failed
transitions status {
pending -> running
running -> completed
running -> failed
}
}
value UploadTarget {
kind: html | thumbnails | media
local_dir: String
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
provides:
UploadSiteRequested(project, credentials)
}
surface PublishingRuntimeSurface {
facing _: PublishRuntime
provides:
PublishJobStarted(project, job, credentials)
PublishTargetFailed(job, target, error)
}
rule UploadSite {
when: UploadSiteRequested(project, credentials)
ensures:
let job = PublishJob.created(
ssh_host: credentials.ssh_host,
ssh_user: credentials.ssh_user,
ssh_remote_path: credentials.ssh_remote_path,
ssh_mode: credentials.ssh_mode,
status: pending
)
job.status = pending
PublishJobStarted(project, job, credentials)
}
rule StartPublishJob {
when: PublishJobStarted(project, job, credentials)
requires: job.status = pending
ensures: job.status = running
ensures: UploadTargetStarted(job, html, "html/", credentials.ssh_remote_path, credentials)
ensures: UploadTargetStarted(job, thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials)
ensures: UploadTargetStarted(job, media, "media/", credentials.ssh_remote_path + "/media", credentials)
}
rule UploadViaScp {
when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials)
requires: credentials.ssh_mode = scp
-- mtime-based upload detection: skip unchanged files
-- Uses SSH agent (SSH_AUTH_SOCK) for authentication
ensures: ScpUploadCompleted(job, target)
ensures: UploadTargetCompleted(job, target)
}
rule UploadViaRsync {
when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials)
requires: credentials.ssh_mode = rsync
-- rsync --update --compress --verbose
-- Media uploads exclude .meta sidecar files
ensures: RsyncUploadCompleted(job, target)
ensures: UploadTargetCompleted(job, target)
@guidance
-- rsync exclude filters for .meta files on media target
}
rule CompletePublishJob {
when: PublishTargetsCompleted(job)
requires: job.status = running
ensures: job.status = completed
}
rule FailPublishJob {
when: PublishTargetFailed(job, target, error)
requires: job.status = running
ensures: job.status = failed
}
rule TrackUploadCompletion {
when: UploadTargetCompleted(job, target)
requires: all_upload_targets_completed(job)
ensures: PublishTargetsCompleted(job)
}
invariant MediaSidecarsExcludedFromUpload {
-- .meta sidecar files are never uploaded to the remote server
-- They are project metadata, not public content
}
invariant PublishJobLifecycle {
-- UploadSiteRequested creates one PublishJob in pending state.
-- PublishJobStarted moves the job to running before any target starts.
-- A job reaches completed only after PublishTargetsCompleted(job).
-- Any PublishTargetFailed(job, target, error) transitions the job to failed.
}
invariant SshAgentAuth {
-- Publishing uses SSH_AUTH_SOCK for key-based authentication
-- No password prompts, no interactive auth
}

713
specs/schema.allium Normal file
View File

@@ -0,0 +1,713 @@
-- allium: 1
-- bDS Persistence Data Contract
-- Scope: core (Wave 1 — exact compatibility contract)
-- Distilled from: ../bDS/src/main/database/schema.ts
--
-- This document specifies the persisted data model the rewrite must be able
-- to read and write. It is the ground truth for storage compatibility.
-- ============================================================================
-- CORE ENTITIES
-- ============================================================================
entity Project {
id: String -- UUID v4
name: String -- Display name
slug: String -- URL-safe identifier
description: String? -- Optional description
data_path: String? -- Custom data directory (null = default)
created_at: Timestamp -- Unix timestamp
updated_at: Timestamp -- Unix timestamp
is_active: Boolean -- Exactly one project is active at a time
}
entity Post {
id: String -- UUID v4
project_id: String
title: String
slug: String -- URL-friendly identifier
excerpt: String? -- Optional summary
content: String? -- Draft body (null when published)
status: draft | published | archived
author: String? -- Author name
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
file_path: String -- Empty for never-published drafts
checksum: String? -- SHA-256 of content
tags: Set<String> -- JSON array stored as text
categories: Set<String> -- JSON array stored as text
template_slug: String? -- User template override
language: String? -- ISO 639-1 code
do_not_translate: Boolean
-- Published snapshot columns (written on publish for diff detection)
published_title: String?
published_content: String?
published_tags: String?
published_categories: String?
published_excerpt: String?
}
entity PostTranslation {
id: String -- UUID v4
project_id: String
translation_for: String -- Canonical post ID
language: String -- ISO 639-1 code
title: String
excerpt: String?
content: String? -- Draft body (null when published)
status: draft | published
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
file_path: String
checksum: String?
}
entity Media {
id: String -- UUID v4
project_id: String
filename: String -- Generated filename
original_name: String -- Original uploaded filename
mime_type: String -- e.g. "image/jpeg"
size: Integer -- Bytes
width: Integer? -- Image dimensions
height: Integer?
title: String?
alt: String?
caption: String?
author: String?
file_path: String -- Absolute path to binary
sidecar_path: String -- Path to .meta sidecar file
created_at: Timestamp
updated_at: Timestamp
checksum: String?
tags: Set<String> -- JSON array stored as text
language: String? -- ISO 639-1 code
}
entity MediaTranslation {
id: String -- UUID v4
project_id: String
translation_for: String -- Canonical media ID
language: String -- ISO 639-1 code
title: String?
alt: String?
caption: String?
created_at: Timestamp
updated_at: Timestamp
}
entity Tag {
id: String -- UUID v4
project_id: String
name: String -- Case-insensitive unique per project
color: String? -- Hex color like #ff0000
post_template_slug: String? -- Template override for this tag
created_at: Timestamp
updated_at: Timestamp
}
entity Template {
id: String -- UUID v4
project_id: String
slug: String -- URL-safe identifier
title: String
kind: post | list | not_found | partial
enabled: Boolean
version: Integer -- Incremented on each update
file_path: String -- templates/{slug}.liquid
status: draft | published
content: String? -- Draft body (null when published)
created_at: Timestamp
updated_at: Timestamp
}
entity Script {
id: String -- UUID v4
project_id: String
slug: String -- URL-safe identifier
title: String
kind: macro | utility | transform
entrypoint: String -- Default: "render" for macros
enabled: Boolean
version: Integer -- Incremented on each update
file_path: String -- scripts/{slug}.{extension}
status: draft | published
content: String? -- Draft body (null when published)
created_at: Timestamp
updated_at: Timestamp
}
-- ============================================================================
-- RELATIONSHIP TABLES
-- ============================================================================
entity PostLink {
id: String -- UUID v4
source_post_id: String -- Post containing the link
target_post_id: String -- Post being linked to
link_text: String? -- Anchor text
created_at: Timestamp
}
entity PostMediaLink {
id: String -- UUID v4
project_id: String
post_id: String
media_id: String
sort_order: Integer -- For ordering media within a post
created_at: Timestamp
}
-- ============================================================================
-- METADATA TABLES
-- ============================================================================
entity Setting {
key: String -- Primary key
value: String -- Serialized value
updated_at: Timestamp
}
entity GeneratedFileHash {
project_id: String
relative_path: String
content_hash: String -- SHA-256 of file content
updated_at: Timestamp
}
-- ============================================================================
-- SEARCH INDEX (FTS5 Virtual Tables)
-- ============================================================================
entity PostSearchIndex {
-- Full-text search index projection, not a user-authored entity
-- Indexed fields: title, excerpt, content, tags, categories
-- Plus all translation titles, excerpts, and content
post: Post
stemmed_content: String -- Processed via Snowball stemmer
}
entity MediaSearchIndex {
-- Full-text search index projection
-- Indexed fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions
media: Media
stemmed_content: String -- Processed via Snowball stemmer
}
-- ============================================================================
-- AI / CHAT TABLES
-- ============================================================================
entity ChatConversation {
id: String -- UUID v4
title: String
model: String? -- Model used for conversation
copilot_session_id: String? -- Legacy, no longer used
created_at: Timestamp
updated_at: Timestamp
}
entity ChatMessage {
id: Integer -- Auto-increment
conversation_id: String
role: system | user | assistant | tool
content: String?
tool_call_id: String? -- For tool responses
tool_calls: String? -- JSON array of tool calls
created_at: Timestamp
}
entity AiProvider {
-- Provider catalog, populated from upstream model registry.
-- Managed by the application and treated as read-only during normal use.
id: String -- PRIMARY KEY
name: String
env: String? -- Environment variable for API key
package_ref: String? -- Legacy package reference
api: String? -- Base API URL
doc: String? -- Documentation URL
updated_at: Timestamp
}
entity AiModel {
-- Full model catalog with capability metadata.
-- Composite primary key: (provider, model_id).
provider: AiProvider
model_id: String
name: String
family: String?
attachment: Boolean -- supports file attachments
reasoning: Boolean -- supports chain-of-thought
tool_call: Boolean -- supports tool/function calling
structured_output: Boolean
temperature: Boolean -- supports temperature parameter
knowledge: String? -- training data cutoff
release_date: String?
last_updated_date: String?
open_weights: Boolean
input_price: Integer? -- price per million input tokens
output_price: Integer? -- price per million output tokens
cache_read_price: Integer?
cache_write_price: Integer?
context_window: Integer
max_input_tokens: Integer
max_output_tokens: Integer
interleaved: String? -- interleaved capability descriptor
status: String? -- active | deprecated | preview
provider_package_ref: String? -- provider-specific legacy package reference
updated_at: Timestamp
}
entity AiModelModality {
-- Input/output modality declarations per model.
provider: AiProvider
model_id: String
direction: String -- "input" | "output"
modality: String -- "text" | "image" | "audio" | "video"
}
entity AiCatalogMeta {
key: String -- "{endpoint_kind}_etag" | "{endpoint_kind}_lastFetchedAt"
value: String
}
-- ============================================================================
-- EMBEDDINGS TABLES
-- ============================================================================
entity EmbeddingKey {
label: Integer -- USearch bigint key
post_id: String
project_id: String
content_hash: String -- SHA-256 of title+content
vector: String -- Encoded vector payload (1536 bytes for 384-dim)
}
entity DismissedDuplicatePair {
id: String -- UUID v4
project_id: String
post_id_a: String
post_id_b: String
dismissed_at: Timestamp
}
-- ============================================================================
-- IMPORT TABLES
-- ============================================================================
entity ImportDefinition {
id: String -- UUID v4
project_id: String
name: String
wxr_file_path: String? -- WordPress XML export file
uploads_folder_path: String? -- WordPress uploads directory
last_analysis_result: String? -- JSON text of ImportAnalysisReport
created_at: Timestamp
updated_at: Timestamp
}
-- ============================================================================
-- NOTIFICATION TABLES
-- ============================================================================
entity DbNotification {
id: Integer -- Auto-increment
entity_type: String -- 'post' | 'media' | 'script' | 'template'
entity_id: String
action: created | updated | deleted
from_cli: Boolean -- 1 = written by CLI
seen_at: Timestamp? -- NULL = unprocessed
created_at: Timestamp
}
surface ProjectRecordSurface {
context project: Project
exposes:
project.id
project.name
project.slug
project.description when project.description != null
project.data_path when project.data_path != null
project.created_at
project.updated_at
project.is_active
}
surface PostTranslationRecordSurface {
context translation: PostTranslation
exposes:
translation.id
translation.project_id
translation.translation_for
translation.language
translation.title
translation.excerpt when translation.excerpt != null
translation.content when translation.content != null
translation.status
translation.created_at
translation.updated_at
translation.published_at when translation.published_at != null
translation.file_path
translation.checksum when translation.checksum != null
}
surface MediaTranslationRecordSurface {
context translation: MediaTranslation
exposes:
translation.id
translation.project_id
translation.translation_for
translation.language
translation.title when translation.title != null
translation.alt when translation.alt != null
translation.caption when translation.caption != null
translation.created_at
translation.updated_at
}
surface TagRecordSurface {
context tag: Tag
exposes:
tag.id
tag.project_id
tag.name
tag.color when tag.color != null
tag.post_template_slug when tag.post_template_slug != null
tag.created_at
tag.updated_at
}
surface TemplateRecordSurface {
context template: Template
exposes:
template.id
template.project_id
template.slug
template.title
template.kind
template.enabled
template.version
template.file_path
template.status
template.content when template.content != null
template.created_at
template.updated_at
}
surface ScriptRecordSurface {
context script: Script
exposes:
script.id
script.project_id
script.slug
script.title
script.kind
script.entrypoint
script.enabled
script.version
script.file_path
script.status
script.content when script.content != null
script.created_at
script.updated_at
}
surface PostLinkRecordSurface {
context link: PostLink
exposes:
link.id
link.source_post_id
link.target_post_id
link.link_text when link.link_text != null
link.created_at
}
surface PostMediaLinkRecordSurface {
context link: PostMediaLink
exposes:
link.id
link.project_id
link.post_id
link.media_id
link.sort_order
link.created_at
}
surface SettingRecordSurface {
context setting: Setting
exposes:
setting.key
setting.value
setting.updated_at
}
surface GeneratedFileHashRecordSurface {
context record: GeneratedFileHash
exposes:
record.project_id
record.relative_path
record.content_hash
record.updated_at
}
surface PostSearchIndexRecordSurface {
context record: PostSearchIndex
exposes:
record.post
record.stemmed_content
}
surface MediaSearchIndexRecordSurface {
context record: MediaSearchIndex
exposes:
record.media
record.stemmed_content
}
surface ChatConversationRecordSurface {
context conversation: ChatConversation
exposes:
conversation.id
conversation.title
conversation.model when conversation.model != null
conversation.copilot_session_id when conversation.copilot_session_id != null
conversation.created_at
conversation.updated_at
}
surface ChatMessageRecordSurface {
context message: ChatMessage
exposes:
message.id
message.conversation_id
message.role
message.content when message.content != null
message.tool_call_id when message.tool_call_id != null
message.tool_calls when message.tool_calls != null
message.created_at
}
surface AiModelRecordSurface {
context model: AiModel
exposes:
model.provider
model.model_id
model.name
model.family when model.family != null
model.attachment
model.reasoning
model.tool_call
model.structured_output
model.temperature
model.knowledge when model.knowledge != null
model.release_date when model.release_date != null
model.last_updated_date when model.last_updated_date != null
model.open_weights
model.input_price when model.input_price != null
model.output_price when model.output_price != null
model.cache_read_price when model.cache_read_price != null
model.cache_write_price when model.cache_write_price != null
model.context_window
model.max_input_tokens
model.max_output_tokens
model.interleaved when model.interleaved != null
model.status when model.status != null
model.provider_package_ref when model.provider_package_ref != null
model.updated_at
}
surface AiModelModalityRecordSurface {
context modality: AiModelModality
exposes:
modality.provider
modality.model_id
modality.direction
modality.modality
}
surface AiCatalogMetaRecordSurface {
context meta: AiCatalogMeta
exposes:
meta.key
meta.value
}
surface EmbeddingKeyRecordSurface {
context key: EmbeddingKey
exposes:
key.label
key.post_id
key.project_id
key.content_hash
key.vector
}
surface DismissedDuplicatePairRecordSurface {
context pair: DismissedDuplicatePair
exposes:
pair.id
pair.project_id
pair.post_id_a
pair.post_id_b
pair.dismissed_at
}
surface ImportDefinitionRecordSurface {
context definition: ImportDefinition
exposes:
definition.id
definition.project_id
definition.name
definition.wxr_file_path when definition.wxr_file_path != null
definition.uploads_folder_path when definition.uploads_folder_path != null
definition.last_analysis_result when definition.last_analysis_result != null
definition.created_at
definition.updated_at
}
surface DbNotificationRecordSurface {
context notification: DbNotification
exposes:
notification.id
notification.entity_type
notification.entity_id
notification.action
notification.from_cli
notification.seen_at when notification.seen_at != null
notification.created_at
}
surface Fts5PostSchemaSurface {
context schema: Fts5PostSchema
exposes:
schema.fields
schema.stemmer_languages
}
surface Fts5MediaSchemaSurface {
context schema: Fts5MediaSchema
exposes:
schema.fields
schema.stemmer_languages
}
surface MigrationVersionSurface {
context _: MigrationVersion
}
-- ============================================================================
-- SCHEMA CONSTRAINTS AND INDEXES
-- ============================================================================
invariant UniqueProjectSlug {
-- projects.slug must be unique across all projects
}
invariant UniquePostSlugPerProject {
-- posts.slug must be unique within each project.project_id
-- Enforced by: posts_project_slug_idx unique index
}
invariant UniqueTranslationPerPostLanguage {
-- post_translations must have unique (translation_for, language)
-- Enforced by: post_translations_translation_language_idx
}
invariant UniqueMediaTranslationPerMediaLanguage {
-- media_translations must have unique (translation_for, language)
-- Enforced by: media_translations_translation_language_idx
}
invariant UniqueTagNamePerProject {
-- tags.name must be unique within each project.project_id
-- Enforced by: tags_project_name_idx unique index
}
invariant UniqueScriptSlugPerProject {
-- scripts.slug must be unique within each project.project_id
-- Enforced by: scripts_project_slug_idx unique index
}
invariant UniqueTemplateSlugPerProject {
-- templates.slug must be unique within each project.project_id
-- Enforced by: templates_project_slug_idx unique index
}
invariant UniquePostMediaLink {
-- post_media must have unique (post_id, media_id) pair
-- Enforced by: post_media_post_media_idx unique index
}
invariant UniqueGeneratedFileHash {
-- generated_file_hashes must have unique (project_id, relative_path)
-- Enforced by: generated_file_hashes_project_path_idx unique index
}
invariant UniqueDismissedDuplicatePair {
-- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b)
-- Enforced by: dismissed_pairs_idx unique index
}
-- ============================================================================
-- FTS5 VIRTUAL TABLE SCHEMAS (Snowball Stemmer Integration)
-- ============================================================================
value Fts5PostSchema {
-- CREATE VIRTUAL TABLE posts_fts USING fts5(
-- post_id UNINDEXED,
-- title, excerpt, content, tags, categories
-- );
-- Standalone table (no content-sync) because text is pre-stemmed
-- via Snowball before insertion; content-sync would read un-stemmed
-- base-table text at query time instead.
fields: Set<String> -- {post_id UNINDEXED, title, excerpt, content, tags, categories}
stemmer_languages: Integer = 24
}
value Fts5MediaSchema {
-- CREATE VIRTUAL TABLE media_fts USING fts5(
-- media_id UNINDEXED,
-- title, alt, caption, original_name, tags
-- );
-- Standalone table (no content-sync) — same rationale as posts_fts.
fields: Set<String> -- {media_id UNINDEXED, title, alt, caption, original_name, tags}
stemmer_languages: Integer = 24
}
-- ============================================================================
-- MIGRATION HISTORY
-- ============================================================================
value MigrationVersion {
-- Schema version tracking via refinery migrations
-- Current version: 0007 (scripts and templates draft lifecycle)
-- Migration files located in: migrations/
-- Note: Migration list documented in comments, not as Allium value
}

188
specs/script.allium Normal file
View File

@@ -0,0 +1,188 @@
-- allium: 1
-- bDS Scripting System
-- Scope: core (Wave 6 — scripting behaviour and file contracts)
-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts
-- The scripting runtime is intentionally unspecified here; only behavioural
-- contracts are normative.
config {
script_extension: String = "script"
}
entity Script {
slug: String
title: String
kind: macro | utility | transform
entrypoint: String -- default: "render" for macros
enabled: Boolean
status: draft | published
content: String?
version: Integer
file_path: String
created_at: Timestamp
updated_at: Timestamp
-- Derived
content_location: if status = published: file_path else: content
transitions status {
draft -> published
published -> draft
}
}
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
provides:
CreateScriptRequested(title, kind, content, entrypoint)
CreateAndPublishScriptRequested(title, kind, content, entrypoint)
UpdateScriptRequested(script, changes)
PublishScriptRequested(script)
DeleteScriptRequested(script)
RunUtilityRequested(script)
MacroExpansionRequested(script, template_context)
BlogmarkReceived(data)
RebuildScriptsFromFilesRequested(project)
}
invariant UniqueScriptSlug {
for a in Scripts:
for b in Scripts:
a != b implies a.slug != b.slug
}
invariant ScriptFileLayout {
for s in Scripts where file_path != "":
s.file_path = format("scripts/{slug}.{extension}", slug: s.slug, extension: config.script_extension)
}
-- Script files use standard --- YAML frontmatter
rule CreateScript {
when: CreateScriptRequested(title, kind, content, entrypoint)
let slug = slugify(title)
-- Creates a draft script: content stored in DB, no file written yet
ensures:
let new_script = Script.created(
slug: slug,
title: title,
kind: kind,
content: content,
entrypoint: entrypoint ?? "render",
status: draft,
enabled: true,
version: 1,
file_path: ""
)
new_script.status = draft
}
rule UpdateScript {
when: UpdateScriptRequested(script, changes)
ensures: ScriptFieldsUpdated(script, changes)
ensures: script.updated_at = now
ensures: script.version = script.version + 1
}
rule ReopenPublishedScript {
when: UpdateScriptRequested(script, changes)
requires: script.status = published
requires: script_changes_affect_published_output(changes)
ensures: script.status = draft
}
rule CreateAndPublishScript {
-- Alternative creation path: create + immediately publish (file written)
-- Some implementations may expose this as a single user action
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint)
let slug = slugify(title)
requires: ValidateScript(content) = valid
ensures:
let new_script = Script.created(
slug: slug,
title: title,
kind: kind,
content: null,
entrypoint: entrypoint ?? "render",
status: published,
enabled: true,
version: 1,
file_path: format("scripts/{slug}.{extension}", slug: slug, extension: config.script_extension)
)
ScriptFileWritten(new_script)
}
rule PublishScript {
when: PublishScriptRequested(script)
requires: script.status = draft
requires: ValidateScript(script.content) = valid
-- AST parsing must succeed
ensures: script.status = published
ensures: ScriptFileWritten(script)
ensures: script.content = null
}
rule DeleteScript {
when: DeleteScriptRequested(script)
ensures: not exists script
ensures: ScriptFileDeleted(script)
}
-- Script execution contracts by kind
rule ExecuteMacro {
when: MacroExpansionRequested(script, template_context)
requires: script.kind = macro
requires: script.enabled = true
-- Macro scripts are invoked during template rendering
-- via [[slug param1=value1 param2=value2]] syntax in post content
-- They receive named parameters and the template context, return HTML
ensures: MacroOutputProduced(script, html_output)
}
rule ExecuteUtility {
when: RunUtilityRequested(script)
requires: script.kind = utility
requires: script.enabled = true
-- Runs on-demand from the UI, produces stdout output
ensures: UtilityOutputProduced(script, stdout)
}
rule ExecuteTransform {
when: BlogmarkReceived(data)
-- Transform scripts run sequentially on blogmark deep link data
-- Input: title, content, tags, categories, source url
-- Each transform can modify the data before post creation
let transforms = Scripts where kind = transform and enabled = true
for t in ordered_by(transforms, s => s.slug):
ensures: TransformApplied(t, data)
@guidance
-- bds://new-post deep links from browser bookmarks
-- Max 5 toast notifications per script, 20 total
}
rule RebuildScriptsFromFiles {
when: RebuildScriptsFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/scripts", "*." + config.script_extension):
let parsed = parse_script_file(file)
ensures: Script.created(parsed)
}

118
specs/search.allium Normal file
View File

@@ -0,0 +1,118 @@
-- allium: 1
-- bDS Full-Text Search
-- Scope: core (Wave 1 — in-app full-text search with Snowball stemmers)
-- Distilled from: src/main/engine/PostEngine.ts (FTS methods),
-- MediaEngine.ts (FTS methods), stemmer.ts
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
-- Applied to both indexing and query processing
code: String
}
surface StemmerLanguageSurface {
context language: StemmerLanguage
exposes:
language.code
}
entity PostSearchIndex {
-- Full-text index projection
-- Indexed fields: title, excerpt, content, tags, categories
-- Plus all translation titles, excerpts, and content
post: post/Post
stemmed_content: String
}
entity MediaSearchIndex {
-- Full-text index projection
-- Indexed fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions
media: media/Media
stemmed_content: String
}
invariant CrossLanguageStemming {
-- Search index uses Snowball stemmer matched to content language
-- A post in German is stemmed with the German stemmer
-- Translations are stemmed with their respective language stemmers
-- Query-time stemming matches the index language
}
rule SearchPosts {
when: SearchPostsRequested(query, filters)
-- Full-text search with optional filters:
-- status, tags, categories, language, missingTranslationLanguage,
-- year, month, date range (from/to)
-- Returns paginated results with total count
let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(PostSearchIndex, stemmed_query, filters)
ensures: SearchResults(
posts: matched,
total: matched.count,
offset: filters.offset,
limit: filters.limit
)
}
rule SearchMedia {
when: SearchMediaRequested(query)
let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(MediaSearchIndex, stemmed_query)
ensures: SearchResults(
media: matched
)
}
rule IndexPost {
when: SearchIndexUpdated(post)
-- Stems: title + excerpt + content + tags + categories
-- Plus all translations' title + excerpt + content
let all_text = concat_post_text(post)
-- Concatenates: post.title, post.excerpt, post.content,
-- join(post.tags, " "), join(post.categories, " "),
-- and all translations' title, excerpt, content
let index_entry = PostSearchIndex{post: post}
ensures:
if exists index_entry:
index_entry.stemmed_content = stem(all_text)
else:
PostSearchIndex.created(post: post, stemmed_content: stem(all_text))
}
rule IndexMedia {
when: SearchIndexUpdated(media)
-- Stems: title + alt + caption + original_name + tags
-- Plus all translations' title, alt, caption
let all_text = concat_media_text(media)
-- Concatenates: media.title, media.alt, media.caption,
-- media.original_name, join(media.tags, " "),
-- and all translations' title, alt, caption
let index_entry = MediaSearchIndex{media: media}
ensures:
if exists index_entry:
index_entry.stemmed_content = stem(all_text)
else:
MediaSearchIndex.created(media: media, stemmed_content: stem(all_text))
}

799
specs/sidebar_views.allium Normal file
View File

@@ -0,0 +1,799 @@
-- allium: 1
-- bDS Sidebar Views
-- Scope: UI content (all waves + extensions)
-- Distilled from: Sidebar.tsx, PostsList.tsx, MediaList.tsx,
-- SidebarEntityList.tsx, SettingsNav.tsx, TagsNav.tsx,
-- ChatList.tsx, ImportList.tsx, ScriptsList.tsx, TemplatesList.tsx,
-- GitSidebar.tsx, sidebarDateFormatting.ts
-- Describes the content and behaviour of each of the 10 sidebar views.
-- The sidebar shell (visibility, resize, view switching) is in layout.allium.
-- Tab opening behaviour is in tabs.allium.
use "./layout.allium" as layout
use "./tabs.allium" as tabs
use "./post.allium" as post
use "./media.allium" as media
use "./tag.allium" as tag
use "./i18n.allium" as i18n
-- ─── Sidebar view registry ───────────────────────────────────
-- 10 views: posts, pages, media, scripts, templates, settings, tags, chat, import, git
-- Default view: posts
-- The sidebar renders exactly one view at a time, selected by active_view.
-- Each ActivityId maps 1:1 to a SidebarView of the same name.
config {
default_sidebar_view: String = "posts"
}
-- ─── Shared patterns ─────────────────────────────────────────
value LocaleMapping {
ui_locale: String -- en | de | fr | it | es
format_locale: String -- en-US | de-DE | fr-FR | it-IT | es-ES
}
default LocaleMapping en_locale = { ui_locale: "en", format_locale: "en-US" }
default LocaleMapping de_locale = { ui_locale: "de", format_locale: "de-DE" }
default LocaleMapping fr_locale = { ui_locale: "fr", format_locale: "fr-FR" }
default LocaleMapping it_locale = { ui_locale: "it", format_locale: "it-IT" }
default LocaleMapping es_locale = { ui_locale: "es", format_locale: "es-ES" }
config {
fallback_format_locale: String = "en-US"
}
value RelativeDateFormat {
timestamp: Timestamp
locale: String
diff_days: Integer -- (today - timestamp.date).days
-- Derived
display: String =
if diff_days = 0: timestamp.toLocaleTimeString(locale)
else if diff_days = 1: i18n/translate("sidebar.chat.yesterday", locale)
else if diff_days < 7: timestamp.toLocaleDateString(locale, weekday: short)
else: timestamp.toLocaleDateString(locale, month: short, day: numeric)
}
surface RelativeDateFormatSurface {
context format: RelativeDateFormat
exposes:
format.timestamp
format.locale
format.diff_days
format.display
}
value PostDateFormat {
timestamp: Timestamp
locale: String
-- Derived
display: String = timestamp.toLocaleDateString(locale, month: short, day: numeric, year: numeric)
-- Example: "Feb 10, 2026"
}
surface PostDateFormatSurface {
context format: PostDateFormat
exposes:
format.timestamp
format.locale
format.display
}
value PostTypeIcon {
categories: List<String>
-- Derived: first category match wins, case-insensitive
icon: String =
if categories.any(c => lowercase(c) in {"picture", "photo", "image"}): "camera"
else if categories.any(c => lowercase(c) in {"aside", "note", "quick"}): "notepad"
else if categories.any(c => lowercase(c) in {"link", "bookmark"}): "link"
else if categories.any(c => lowercase(c) = "video"): "film"
else if categories.any(c => lowercase(c) = "quote"): "speech_bubble"
else: "document"
}
surface PostTypeIconSurface {
context icon: PostTypeIcon
exposes:
icon.categories
icon.icon
}
invariant SidebarEntityListPattern {
-- Views following this pattern (scripts, templates, chat, import) must provide:
-- 1. Header with localised title and create button.
-- 2. Scrollable list of items.
-- 3. Empty state with localised message and action call-to-action when items list is empty.
-- 4. All text in list items uses CSS text-overflow:ellipsis on sidebar width overflow.
}
-- ─── 1. Posts view ────────────────────────────────────────────
value PostsView {
mode: String -- "posts" or "pages"
search_query: String? -- FTS via posts.search(query)
filter_panel_visible: Boolean -- collapsible, toggled by icon button
calendar_filter: CalendarFilter? -- year/month archive tree
tag_filter: List<String> -- selected tags (multi-select chips with colours)
category_filter: List<String> -- selected categories (multi-select chips)
draft_section: List<PostListItem>
published_section: List<PostListItem>
archived_section: List<PostListItem>
has_more: Boolean -- pagination, 500 per batch
}
surface PostsViewSurface {
context view: PostsView
exposes:
view.mode
view.search_query
view.filter_panel_visible
view.calendar_filter when view.calendar_filter != null
view.tag_filter
view.category_filter
view.draft_section.count
view.published_section.count
view.archived_section.count
view.has_more
}
-- Drafts section always shows all drafts regardless of filters.
-- Published and archived sections respect active filters.
-- "Clear All Filters" button resets search, date, tags, categories.
-- Filters auto-refresh when any post's status changes.
value PostListItem {
post_id: String
type_icon: String -- derived via PostTypeIcon from source post categories
title: String -- post.title, fallback "Untitled"
language_count: Integer? -- shown when availableLanguages.count > 1
date: String -- locale-formatted via PostDateFormat
active: Boolean -- true when activeTabId = post.id
}
surface PostListItemEntry {
context item: PostListItem
exposes:
item.type_icon
item.title
item.language_count when item.language_count != null
item.date
item.active
provides:
PostListItemClicked(item.post_id, single)
PostListItemClicked(item.post_id, double)
@guarantee RowLayout
-- Row with two columns.
-- Left column: type_icon (fixed width, top-aligned).
-- Right column, line 1: title (fills available width, truncated with ellipsis)
-- and language_count badge (right-aligned pill, smaller font) when present.
-- Right column, line 2: date (smaller, muted colour).
@guarantee ActiveIndicator
-- When item.active is true, entry shows a coloured left-border accent.
@guarantee PostTypeBackground
-- Row has a subtle background tint derived from the type_icon category.
@guarantee DateSource
-- For published posts: date derives from publishedAt, falling back to updatedAt.
-- For draft and archived posts: date derives from updatedAt.
}
rule PostListClick {
when: PostListItemClicked(post_id, click_type)
if click_type = single:
ensures: OpenTabRequested(type: post, id: post_id, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: post, id: post_id, intent: pin)
}
-- ─── 2. Pages view ───────────────────────────────────────────
-- Identical to PostsView but:
-- mode = "pages"
-- Filters to posts with "page" category
-- Create button auto-adds "page" category
-- Category filter excludes "page" from chips but auto-merges into backend calls
-- ─── 3. Media view ────────────────────────────────────────────
value MediaView {
search_query: String? -- FTS via media.search(query)
filter_panel_visible: Boolean
calendar_filter: CalendarFilter?
tag_filter: List<String> -- tags only, no categories for media
grid: List<MediaGridItem> -- grid layout (not list)
}
surface MediaViewSurface {
context view: MediaView
exposes:
view.search_query
view.filter_panel_visible
view.calendar_filter when view.calendar_filter != null
view.tag_filter
view.grid.count
}
value MediaGridItem {
media_id: String
thumbnail_path: String? -- small (150px) thumbnail on disk when image; null for non-image
name: String -- title truncated to config.media_title_max_length + "..."; fallback originalName (no truncation)
file_size: String -- formatted (B / KB / MB)
dimensions: String? -- "WxH" when width and height known; null otherwise
tooltip: String -- caption ?? originalName
active: Boolean -- true when activeTabId = media.id
}
surface MediaGridItemEntry {
context item: MediaGridItem
exposes:
item.thumbnail_path when item.thumbnail_path != null
item.name
item.file_size
item.dimensions when item.dimensions != null
item.tooltip
item.active
provides:
MediaGridItemClicked(item.media_id, single)
MediaGridItemClicked(item.media_id, double)
@guarantee CellLayout
-- Grid cell, row layout.
-- Left: 40x40 thumbnail (rounded, object-fit cover) loaded from
-- thumbnail_path (small 150px WebP) when present;
-- otherwise generic file icon of same dimensions.
-- Right column, line 1: name (truncated with ellipsis).
-- Right column, line 2: file_size (smaller, muted) followed by
-- dimensions when present, separated by " · ".
@guarantee NameTruncation
-- Title is hard-truncated at config.media_title_max_length characters
-- with "..." suffix appended. originalName is never hard-truncated.
-- CSS text-overflow:ellipsis applies as additional safety net on both.
@guarantee TooltipContent
-- Tooltip shows caption when available, otherwise originalName.
}
config {
media_title_max_length: Integer = 60
}
rule MediaListClick {
when: MediaGridItemClicked(media_id, click_type)
if click_type = single:
ensures: OpenTabRequested(type: media, id: media_id, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: media, id: media_id, intent: pin)
}
-- Import button: opens native file import dialog
-- ─── 4. Scripts view ──────────────────────────────────────────
-- Follows SidebarEntityListPattern.
value ScriptsView {
items: List<ScriptListItem>
}
surface ScriptsViewSurface {
context view: ScriptsView
exposes:
view.items.count
}
value ScriptListItem {
script_id: String
title: String -- truncated with ellipsis on overflow
date: String -- relative date format via RelativeDateFormat
active: Boolean
}
surface ScriptListItemEntry {
context item: ScriptListItem
exposes:
item.title
item.date
item.active
provides:
ScriptListItemClicked(item.script_id, single)
ScriptListItemClicked(item.script_id, double)
ScriptDeleteRequested(item.script_id)
@guarantee RowLayout
-- Row with two areas.
-- Left area (fills width), two lines:
-- Line 1: title (truncated with ellipsis).
-- Line 2: date in relative format (smaller, muted).
-- Right area: delete button (x), visible only on row hover, turns red on hover.
@guarantee KeyboardNavigation
-- Enter key: pin (opens pinned tab).
-- Space key: preview (opens transient tab).
-- Row is focusable with tabIndex and has aria-label from title.
@guarantee DeleteBehaviour
-- Delete removes the script and closes its open tab if any.
@guarantee CreateDefaults
-- New scripts default to: kind=utility, content='print("new script")',
-- entrypoint='render', enabled=true.
@guarantee LiveRefresh
-- Item list refreshes on scripts-changed event.
}
rule ScriptListClick {
when: ScriptListItemClicked(script_id, click_type)
if click_type = single:
ensures: OpenTabRequested(type: scripts, id: script_id, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: scripts, id: script_id, intent: pin)
}
-- ─── 5. Templates view ───────────────────────────────────────
-- Follows SidebarEntityListPattern. Same item layout as ScriptListItemEntry.
value TemplatesView {
items: List<TemplateListItem>
}
surface TemplatesViewSurface {
context view: TemplatesView
exposes:
view.items.count
}
value TemplateListItem {
template_id: String
title: String -- truncated with ellipsis on overflow
date: String -- relative date format via RelativeDateFormat
active: Boolean
}
surface TemplateListItemEntry {
context item: TemplateListItem
exposes:
item.title
item.date
item.active
provides:
TemplateListItemClicked(item.template_id, single)
TemplateListItemClicked(item.template_id, double)
TemplateDeleteRequested(item.template_id)
@guarantee RowLayout
-- Row with two areas.
-- Left area (fills width), two lines:
-- Line 1: title (truncated with ellipsis).
-- Line 2: date in relative format (smaller, muted).
-- Right area: delete button (x), visible only on row hover, turns red on hover.
@guarantee KeyboardNavigation
-- Enter key: pin (opens pinned tab).
-- Space key: preview (opens transient tab).
-- Row is focusable with tabIndex and has aria-label from title.
@guarantee DeleteConfirmation
-- If template is referenced by posts or tags, shows confirmation dialog
-- with reference counts before force-delete.
@guarantee CreateDefaults
-- New templates default to: kind=post, content='', enabled=true.
@guarantee LiveRefresh
-- Item list refreshes on templates-changed event.
}
rule TemplateListClick {
when: TemplateListItemClicked(template_id, click_type)
if click_type = single:
ensures: OpenTabRequested(type: templates, id: template_id, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: templates, id: template_id, intent: pin)
}
-- ─── 6. Settings view ────────────────────────────────────────
-- Navigation list that controls sections within the settings editor tab.
value SettingsNavEntry {
section: String
icon: String
label_key: String
active: Boolean
}
invariant SettingsNavSections {
-- Settings navigation has exactly 9 entries in this fixed order:
-- 1. section="project", icon="folder", label_key="settings.nav.project"
-- 2. section="editor", icon="notepad", label_key="settings.nav.editor"
-- 3. section="content", icon="clipboard", label_key="settings.nav.content"
-- 4. section="ai", icon="robot", label_key="settings.nav.ai"
-- 5. section="technology", icon="gear", label_key="settings.nav.technology"
-- 6. section="publishing", icon="rocket", label_key="settings.nav.publishing"
-- 7. section="data", icon="database", label_key="settings.nav.data"
-- 8. section="mcp", icon="plug", label_key="settings.nav.mcp"
-- 9. section="style", icon="palette", label_key="settings.nav.style"
-- Labels are localised via their label_key through i18n.
}
surface SettingsNavEntryView {
context entry: SettingsNavEntry
exposes:
entry.icon
entry.label_key
entry.active
provides:
SettingsNavEntryClicked(entry.section)
@guarantee RowLayout
-- Row with icon (fixed 20px width, centred) and localised text label.
-- Active entry has distinct selection background and foreground colours.
@guarantee FixedOrder
-- Entries always appear in the order defined by SettingsNavSections invariant.
}
value SettingsNav {
active_section: String? -- persisted across sidebar switches
}
surface SettingsNavSurface {
context nav: SettingsNav
exposes:
nav.active_section when nav.active_section != null
}
rule SettingsNavClick {
when: SettingsNavEntryClicked(section)
if section = style:
ensures: OpenTabRequested(type: style, id: style, intent: pin)
else:
ensures: OpenTabRequested(type: settings, id: settings, intent: pin)
ensures: ScrollToSection(section)
ensures: active_section = section
-- Active section is persisted across sidebar switches
}
-- ─── 7. Tags view ─────────────────────────────────────────────
-- Navigation list that controls sections within the tags editor tab.
value TagsNavEntry {
section: String
icon: String
label_key: String
active: Boolean
}
invariant TagsNavSections {
-- Tags navigation has exactly 3 entries in this fixed order:
-- 1. section="cloud", icon="cloud", label_key="tags.nav.cloud" -- tag cloud visualisation
-- 2. section="manage", icon="pencil", label_key="tags.nav.manage" -- create/edit tags
-- 3. section="merge", icon="merge", label_key="tags.nav.merge" -- merge duplicate tags
-- Labels are localised via their label_key through i18n.
}
surface TagsNavEntryView {
context entry: TagsNavEntry
exposes:
entry.icon
entry.label_key
entry.active
provides:
TagsNavEntryClicked(entry.section)
@guarantee RowLayout
-- Row with icon (fixed 20px width, centred) and localised text label.
-- Active entry has distinct selection background and foreground colours.
-- Same visual structure as SettingsNavEntryView.
@guarantee FixedOrder
-- Entries always appear in the order defined by TagsNavSections invariant.
}
value TagsNav {
active_section: String? -- persisted
}
surface TagsNavSurface {
context nav: TagsNav
exposes:
nav.active_section when nav.active_section != null
}
rule TagsNavClick {
when: TagsNavEntryClicked(section)
ensures: OpenTabRequested(type: tags, id: tags, intent: pin)
ensures: ScrollToSection(section)
ensures: active_section = section
-- Active section is persisted across sidebar switches
}
-- ─── 8. Chat view ─────────────────────────────────────────────
-- Follows SidebarEntityListPattern.
value ChatView {
api_ready: Boolean -- shows API key prompt if false
items: List<ChatListItem>
}
surface ChatViewSurface {
context view: ChatView
exposes:
view.api_ready
view.items.count
}
value ChatListItem {
conversation_id: String
title: String -- live-updated via onTitleUpdated
date: String -- relative date format via RelativeDateFormat
}
surface ChatListItemEntry {
context item: ChatListItem
exposes:
item.title
item.date
provides:
ChatListItemClicked(item.conversation_id)
ChatDeleteRequested(item.conversation_id)
@guarantee RowLayout
-- Row with two areas.
-- Left area (fills width), two lines:
-- Line 1: title (truncated with ellipsis, live-updated).
-- Line 2: date in relative format (smaller, muted).
-- Right area: delete button (x), visible only on row hover.
@guarantee DeleteBehaviour
-- Delete removes the conversation and closes its open tab if any.
@guarantee AlwaysPinned
-- Chat tabs are always opened as pinned (never transient).
}
rule ChatListClick {
when: ChatListItemClicked(conversation_id)
ensures: OpenTabRequested(type: chat, id: conversation_id, intent: pin)
-- Chat tabs are always pinned
}
-- ─── 9. Import view ──────────────────────────────────────────
-- Follows SidebarEntityListPattern.
value ImportView {
items: List<ImportListItem>
}
surface ImportViewSurface {
context view: ImportView
exposes:
view.items.count
}
value ImportListItem {
definition_id: String
name: String -- live-updated via onNameUpdated
date: String -- relative date format via RelativeDateFormat
}
surface ImportListItemEntry {
context item: ImportListItem
exposes:
item.name
item.date
provides:
ImportListItemClicked(item.definition_id)
ImportDeleteRequested(item.definition_id)
@guarantee RowLayout
-- Row with two areas.
-- Left area (fills width), two lines:
-- Line 1: name (truncated with ellipsis, live-updated).
-- Line 2: date in relative format (smaller, muted).
-- Right area: delete button (x), visible only on row hover.
@guarantee DeleteBehaviour
-- Delete removes the import definition and closes its open tab if any.
@guarantee AlwaysPinned
-- Import tabs are always opened as pinned (never transient).
}
rule ImportListClick {
when: ImportListItemClicked(definition_id)
ensures: OpenTabRequested(type: import, id: definition_id, intent: pin)
-- Import tabs are always pinned
}
-- ─── 10. Git view ─────────────────────────────────────────────
-- Full git interface, not the SidebarEntityList pattern.
-- Three possible states: loading, not_a_repo, active_repo.
-- State: not_a_repo
-- Remote URL text input + "Initialize Git" button.
-- Init progress with phase/percentage/detail, collapsible transcript.
-- State: active_repo
value GitActiveView {
branch: String -- current branch name
upstream: String? -- tracking info (local -> upstream)
ahead: Integer
behind: Integer
status_files: List<GitStatusFile>
history_entries: List<GitHistoryEntry>
has_more_history: Boolean -- paginated, 20 per page
}
surface GitActiveViewSurface {
context view: GitActiveView
exposes:
view.branch
view.upstream when view.upstream != null
view.ahead
view.behind
view.status_files.count
view.history_entries.count
view.has_more_history
provides:
GitCommitRequested(message)
}
value GitStatusFile {
path: String
status: String -- modified, added, deleted, renamed, etc.
}
surface GitStatusFileEntry {
context file: GitStatusFile
exposes:
file.path
file.status
provides:
GitStatusFileClicked(file.path, single)
GitStatusFileClicked(file.path, double)
@guarantee RowLayout
-- Row with two elements, justified space-between.
-- Left: file path (truncated with ellipsis, fills available width).
-- Right: status badge (short uppercase code e.g. "M", "A", "D"; muted colour, fixed width).
@guarantee Tooltip
-- Tooltip shows "status: path" (e.g. "modified: src/main.rs").
}
value GitHistoryEntry {
short_hash: String -- 7 chars
subject: String -- wraps (word-break), not truncated
author: String
date: String -- locale-formatted
sync_status: String -- synced, local_only, remote_only
}
surface GitHistoryEntryView {
context entry: GitHistoryEntry
exposes:
entry.short_hash
entry.subject
entry.author
entry.date
entry.sync_status
provides:
GitHistoryEntryClicked(entry.short_hash, single)
GitHistoryEntryClicked(entry.short_hash, double)
@guarantee EntryLayout
-- Two lines.
-- Line 1: subject (wraps with word-break, never truncated).
-- Line 2: short_hash + author + date + sync_status indicator, separated by spacing.
@guarantee SyncStatusIndicator
-- sync_status rendered as a coloured dot.
-- "synced" = both local and remote. "local_only" = local only. "remote_only" = remote only.
-- A colour legend is shown in the git view header.
}
-- Action buttons: fetch, pull, push, prune_lfs. All disabled while any action is loading.
-- Changes section: file count, commit message input, commit button, file list.
-- Changes list polls every 2 seconds in background.
-- Remote state refreshes every 30 seconds with auto-fetch.
rule GitFileClick {
when: GitStatusFileClicked(file_path, click_type)
if click_type = single:
ensures: OpenTabRequested(type: git_diff, id: "git-diff:" + file_path, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: git_diff, id: "git-diff:" + file_path, intent: pin)
}
rule GitHistoryClick {
when: GitHistoryEntryClicked(commit_hash, click_type)
if click_type = single:
ensures: OpenTabRequested(type: git_diff, id: "git-diff:commit:" + commit_hash, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: git_diff, id: "git-diff:commit:" + commit_hash, intent: pin)
}
rule GitCommit {
when: GitCommitRequested(message)
ensures: git.commitAll(message)
-- Also: all git_diff tabs are closed and git state is reloaded
}
-- ─── Calendar archive (shared widget) ─────────────────────────
-- Collapsible year/month tree.
-- Selecting a year loads all posts/media for that year.
-- Selecting a month narrows to that month.
value CalendarFilter {
selected_year: Integer?
selected_month: Integer? -- 1-12
}
value CalendarYear {
year: Integer
months: List<CalendarMonth>
}
surface CalendarYearSurface {
context calendar_year: CalendarYear
exposes:
calendar_year.year
calendar_year.months.count
}
value CalendarMonth {
month: Integer -- 1-12
count: Integer -- number of items in this month
}

247
specs/tabs.allium Normal file
View File

@@ -0,0 +1,247 @@
-- allium: 1
-- bDS Tab System and Editor Routing
-- Scope: UI navigation (all waves)
-- Distilled from: tabPolicy.ts, TabBar.tsx, editorRouting.ts, appStore.ts
-- Governs the tab bar, tab lifecycle (open/close/pin), editor routing,
-- and the relationship between tabs and the content area.
use "./layout.allium" as layout
surface TabControlSurface {
facing _: TabOperator
provides:
OpenTabRequested(type, id, intent)
OpenTabInBackgroundRequested(type, id, intent)
CloseTabRequested(tab)
PinTabRequested(tab)
ClearTabsRequested()
}
surface TabRuntimeSurface {
facing _: TabRuntime
provides:
TabOpening(tab_type, intent)
ActiveTabChanged(active_tab)
}
-- ─── Tab types ────────────────────────────────────────────────
-- 17 distinct tab types, each routing to a matching editor view.
-- Plus "dashboard" as the no-tab default view.
--
-- Tab types: post, media, settings, style, tags, chat, import,
-- menu_editor, metadata_diff, git_diff, documentation,
-- api_documentation, site_validation, translation_validation,
-- scripts, templates, find_duplicates
--
-- Editor routes: all of the above plus "dashboard" (shown when no tab is active).
-- Route registry is 1:1: every tab type maps to itself as an editor route.
-- ─── Tab entity ───────────────────────────────────────────────
value Tab {
type: String -- one of the 17 tab types
id: String -- singleton: id = type name; entity: external ID
is_transient: Boolean -- true = preview tab (italic title, replaceable)
}
surface TabSurface {
context tab: Tab
exposes:
tab.type
tab.id
tab.is_transient
}
-- ─── Tab categories ───────────────────────────────────────────
-- 1. Singleton tool tabs: always one instance, never transient, id = type name.
-- settings, tags, style, scripts (bare), menu_editor, documentation,
-- api_documentation, metadata_diff, site_validation,
-- translation_validation, find_duplicates
-- Total: 11 singleton types.
-- 2. Entity tabs: keyed by external ID, support preview/pin intent.
-- post (id = postId), media (id = mediaId)
-- 3. Script tabs: type = scripts, id = scriptId (NOT the singleton).
-- Support preview/pin intent.
-- 4. Template tabs: type = templates, id = templateId.
-- Support preview/pin intent.
-- 5. Chat tabs: type = chat, id = conversationId. Always pinned (not transient).
-- 6. Import tabs: type = import, id = definitionId. Always pinned.
-- 7. Git diff tabs: type = git_diff.
-- File diff: id = "git-diff:{filePath}"
-- Commit diff: id = "git-diff:commit:{commitHash}"
-- Support preview/pin intent.
-- ─── Open intent ──────────────────────────────────────────────
-- preview: transient tab (replaced by next preview of same type)
-- pin: permanent tab (persists until explicitly closed)
rule DeriveTransient {
when: TabOpening(tab_type, intent)
if tab_type in singleton_tool_tabs:
ensures: tab.is_transient = false
else if tab_type = chat or tab_type = import:
ensures: tab.is_transient = false
else:
ensures: tab.is_transient = (intent = preview)
}
-- ─── Tab lifecycle ────────────────────────────────────────────
rule OpenTab {
when: OpenTabRequested(type, id, intent)
-- Dedup: if tab with same (type, id) already exists, activate it.
-- If intent = pin, also set is_transient = false.
-- Transient replacement: if opening as transient and a transient tab
-- of same type exists, replace it with the new tab.
-- Otherwise: append a new tab.
-- Always sets active_tab to the opened/reused tab.
ensures: active_tab = resolved_tab
}
rule OpenTabInBackground {
when: OpenTabInBackgroundRequested(type, id, intent)
-- Same dedup/replace logic as OpenTab, but does NOT change active_tab
}
rule CloseTab {
when: CloseTabRequested(tab)
ensures: not exists tab
-- If tab was active: activate next tab at same index, or last tab, or null
}
rule PinTab {
when: PinTabRequested(tab)
ensures: tab.is_transient = false
}
rule ClearTabs {
when: ClearTabsRequested()
ensures: tabs = empty
ensures: active_tab = null
}
-- ─── Editor routing ───────────────────────────────────────────
-- The editor content area renders a view based on the active tab.
rule ResolveEditorRoute {
when: ActiveTabChanged(active_tab)
if active_tab = null:
ensures: editor_route = dashboard
else:
ensures: editor_route = active_tab.type
-- 1:1 mapping; every tab type maps to itself as editor route
}
-- ─── Editor views (what each route renders) ───────────────────
-- dashboard: Overview stats, timeline, tag cloud, recent posts
-- post: Post editor (keyed by postId)
-- media: Media editor (keyed by mediaId)
-- settings: Settings view with scrollable sections
-- style: Pico CSS theme editor
-- tags: Tag cloud, create/edit, merge sections
-- chat: AI chat panel (keyed by conversationId)
-- import: Import analysis view (keyed by definitionId)
-- menu_editor: OPML menu editor
-- metadata_diff: DB vs filesystem diff viewer
-- git_diff: Git diff view (file or commit, keyed by tab id)
-- documentation: Rendered markdown (DOCUMENTATION.md)
-- api_documentation: Rendered markdown (API.md)
-- site_validation: Generated site link/structure validation
-- translation_validation: Translation completeness checks
-- scripts: Script editor (keyed by scriptId)
-- templates: Template editor (keyed by templateId)
-- find_duplicates: Duplicate post detection via embeddings
-- ─── Tab bar rendering ───────────────────────────────────────
-- Hidden when no tabs exist.
-- Horizontal strip with overflow scroll (left/right arrow buttons, 150px per click).
-- Auto-scrolls to bring active tab into view (10px padding).
config {
tab_min_width: Integer = 100
tab_max_width: Integer = 160
tab_scroll_step: Integer = 150
chat_title_max_length: Integer = 18
git_hash_display_length: Integer = 7
}
value TabBarItem {
title: String -- resolved per type (see below); CSS ellipsis at tab_max_width
is_active: Boolean
is_transient: Boolean -- italic title when true
is_dirty: Boolean -- dot indicator, only for post tabs
}
surface TabBarItemSurface {
context item: TabBarItem
exposes:
item.title
item.is_active
item.is_transient
item.is_dirty
}
-- Tab title resolution:
-- post: post.title from DB (listens post-updated events); no JS truncation
-- media: media.originalName; no JS truncation
-- scripts: script.title from DB (listens scripts-changed); no JS truncation
-- templates: template.title from DB (listens templates-changed); no JS truncation
-- chat: conversation.title, JS-truncated to 18 chars + "..." if over limit
-- import: definition.name (listens name-updated); no JS truncation
-- git_diff file: filename only (last path segment); no JS truncation
-- git_diff commit: "{shortHash} {subject}" (shortHash = 7 chars); fallback: 7-char hash only
-- singletons: i18n key lookup (common.settings, tabBar.style, etc.)
-- fallback: i18n:tabBar.unknown
--
-- All tab titles are additionally CSS-truncated (text-overflow:ellipsis, white-space:nowrap)
-- within the tab's max-width of 160px.
-- ─── Tab interactions ─────────────────────────────────────────
-- Single click on tab: activate it
-- Double click on tab: if transient, pin it
-- Middle click on tab: close it
-- Close button: close the tab
-- ─── Dirty tracking ──────────────────────────────────────────
invariant DirtyIndicator {
-- Only post tabs show dirty state
-- A post tab is dirty when its in-memory content differs from saved
for tab in tabs:
tab.is_dirty = (tab.type = post and dirtyPosts.contains(tab.id))
}
-- ─── Tab tooltip ──────────────────────────────────────────────
-- Base: tab title
-- If transient: append " (Preview)"
-- If dirty: append " * Modified"
-- ─── Keyboard shortcuts ──────────────────────────────────────
-- Ctrl/Cmd+W: close active tab
-- Ctrl/Cmd+B: toggle sidebar (see layout.allium)
-- ─── Tab state persistence ───────────────────────────────────
-- Tab state (list + activeTabId) can be serialized/restored
-- for session continuity across project switches.

121
specs/tag.allium Normal file
View File

@@ -0,0 +1,121 @@
-- allium: 1
-- bDS Tag System
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/TagEngine.ts, schema.ts
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
color: String? -- hex color code
post_template_slug: String?
created_at: Timestamp
updated_at: Timestamp
-- Derived
posts: post/Post with this.name in tags
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:
for b in Tags:
(a != b and a.project = b.project)
implies lowercase(a.name) != lowercase(b.name)
}
invariant TagsPersistToFilesystem {
-- meta/tags.json is the portable format (no internal IDs)
-- Must stay in sync with DB tag table
parse_json(read_file("meta/tags.json")) = serialize_portable(Tags)
}
rule CreateTag {
when: CreateTagRequested(project, name, color)
let existing_tags = Tags where project = project
requires: not existing_tags.any(t => lowercase(t.name) = lowercase(name))
-- Case-insensitive duplicate check
ensures: Tag.created(
project: project,
name: name,
color: color
)
ensures: TagsFileWritten(project)
}
rule UpdateTag {
when: UpdateTagRequested(tag, changes)
ensures: TagFieldsUpdated(tag, changes)
ensures: tag.updated_at = now
ensures: TagsFileWritten(tag.project)
}
rule DeleteTag {
when: DeleteTagRequested(tag)
-- Runs as background task, removes tag from all posts
for p in tag.posts:
ensures: p.tags = p.tags - {tag.name}
ensures: not exists tag
ensures: TagsFileWritten(tag.project)
}
rule RenameTag {
when: RenameTagRequested(tag, new_name)
-- Runs as background task
let old_name = tag.name
for p in tag.posts:
ensures: p.tags = (p.tags - {old_name}) + {new_name}
ensures: tag.name = new_name
ensures: TagsFileWritten(tag.project)
}
rule MergeTags {
when: MergeTagsRequested(sources, target)
-- Runs as background task
-- Merges multiple source tags into a single target
requires: sources.count >= 1
for source in sources:
for p in source.posts:
ensures: p.tags = (p.tags - {source.name}) + {target.name}
ensures: not exists source
ensures: TagsFileWritten(target.project)
}
rule SyncTagsFromPosts {
when: SyncTagsFromPostsRequested(project)
-- Discovers tags used in posts that are not in the tags table
for post in project.posts:
for tag_name in post.tags:
if not exists Tag{project: project, name: tag_name}:
ensures: Tag.created(project: project, name: tag_name)
ensures: TagsFileWritten(project)
}

124
specs/task.allium Normal file
View File

@@ -0,0 +1,124 @@
-- allium: 1
-- bDS Background Task Manager
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/TaskManager.ts
entity Task {
name: String
status: pending | running | completed | failed | cancelled
progress: Decimal? -- 0.0..1.0
message: String?
group_id: String? -- Optional task grouping
group_name: String?
created_at: Timestamp
transitions status {
pending -> running
running -> completed
running -> failed
running -> cancelled
}
}
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
}
invariant MaxConcurrency {
-- At most max_concurrent tasks run simultaneously
let running_tasks = Tasks where status = running
running_tasks.count <= config.max_concurrent
}
invariant FifoQueue {
-- When max concurrent reached, new tasks queue in FIFO order
-- Queued tasks transition to running as slots open
}
rule SubmitTask {
when: SubmitTaskRequested(name, work)
let running_tasks = Tasks where status = running
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)
}
rule CompleteTask {
when: TaskWorkCompleted(task)
ensures: task.status = completed
ensures: task.progress = 1.0
ensures: NextQueuedTaskStarted()
}
rule FailTask {
when: TaskWorkFailed(task, error_message)
ensures: task.status = failed
ensures: task.message = error_message
ensures: NextQueuedTaskStarted()
}
rule CancelTask {
when: CancelTaskRequested(task)
requires: task.status = running or task.status = pending
-- Cancellation uses a runtime-specific cancellation mechanism
ensures: task.status = cancelled
ensures: NextQueuedTaskStarted()
}
rule ReportProgress {
when: ProgressReported(task, value, message)
-- Progress events throttled to 250ms
ensures: task.progress = value
ensures: task.message = message
}
invariant ProgressThrottled {
-- Progress update events are throttled to prevent UI flooding
-- At most one progress event per 250ms per task
}
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask {
when: RegisterExternalTaskRequested(name)
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
}

211
specs/template.allium Normal file
View File

@@ -0,0 +1,211 @@
-- allium: 1
-- bDS Liquid Template System
-- Scope: core (Wave 1 data, Wave 4 rendering)
-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts,
-- bundled starter templates in src/main/engine/templates/
entity Template {
slug: String
title: String
kind: post | list | not_found | partial
enabled: Boolean
status: draft | published
content: String?
version: Integer
file_path: String
created_at: Timestamp
updated_at: Timestamp
-- Derived
content_location: if status = published: file_path else: content
referencing_posts: Posts where template_slug = this.slug
referencing_tags: Tags where post_template_slug = this.slug
transitions status {
draft -> published
published -> draft
}
}
surface TemplateManagementSurface {
facing _: TemplateOperator
provides:
CreateTemplateRequested(title, kind, content)
CreateAndPublishTemplateRequested(title, kind, content)
UpdateTemplateRequested(template, changes)
PublishTemplateRequested(template)
DeleteTemplateRequested(template)
RebuildTemplatesFromFilesRequested(project)
}
invariant UniqueTemplateSlug {
for a in Templates:
for b in Templates:
a != b implies a.slug != b.slug
}
invariant TemplateFrontmatter {
-- .liquid files use standard --- YAML frontmatter
-- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt
for t in Templates where status = published:
parse_frontmatter(read_file(t.file_path)).slug = t.slug
}
invariant TemplateFileLayout {
for t in Templates where file_path != "":
t.file_path = format("templates/{slug}.liquid", slug: t.slug)
}
rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content)
let slug = slugify(title)
-- Creates a draft template: content stored in DB, no file written yet
ensures:
let new_template = Template.created(
slug: slug,
title: title,
kind: kind,
content: content,
status: draft,
enabled: true,
version: 1,
file_path: ""
)
new_template.status = draft
}
rule CreateAndPublishTemplate {
-- Alternative creation path: create + immediately publish (file written)
-- Some implementations may expose this as a single user action
when: CreateAndPublishTemplateRequested(title, kind, content)
let slug = slugify(title)
requires: ValidateLiquid(content) = valid
ensures:
let new_template = Template.created(
slug: slug,
title: title,
kind: kind,
content: null,
status: published,
enabled: true,
version: 1,
file_path: format("templates/{slug}.liquid", slug: slug)
)
TemplateFileWritten(new_template)
}
rule UpdateTemplate {
when: UpdateTemplateRequested(template, changes)
ensures: TemplateFieldsUpdated(template, changes)
ensures: template.updated_at = now
ensures: template.version = template.version + 1
}
rule ReopenPublishedTemplate {
when: UpdateTemplateRequested(template, changes)
requires: template.status = published
requires: template_changes_affect_rendered_output(changes)
ensures: template.status = draft
}
rule PublishTemplate {
when: PublishTemplateRequested(template)
requires: template.status = draft
requires: ValidateLiquid(template.content) = valid
-- The template parser must accept the template
ensures: template.status = published
ensures: TemplateFileWritten(template)
-- Writes frontmatter + liquid to templates/{slug}.liquid
ensures: template.content = null
}
rule DeleteTemplate {
when: DeleteTemplateRequested(template)
requires: template.referencing_posts.count = 0
requires: template.referencing_tags.count = 0
-- Cannot delete a template still referenced by posts or tags
ensures: not exists template
ensures: TemplateFileDeleted(template)
}
rule CascadeSlugUpdate {
when: template: Template.slug transitions_to new_slug
-- When a template slug changes, update all references
for p in template.referencing_posts:
ensures: p.template_slug = new_slug
for t in template.referencing_tags:
ensures: t.post_template_slug = new_slug
}
rule RebuildTemplatesFromFiles {
when: RebuildTemplatesFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/templates", "*.liquid"):
let parsed = parse_template_file(file)
ensures: Template.created(parsed)
-- or updated if slug already exists
}
-- Exact Liquid subset required (distilled from bundled starter templates)
-- No features beyond this list are used.
invariant LiquidTagSubset {
-- Only these 5 tags are used:
-- {% if %} / {% elsif %} / {% else %} / {% endif %}
-- {% for %} / {% endfor %}
-- {% assign %}
-- {% render 'partial', named_param: value %} (with named parameters)
-- Whitespace-stripped variants: {%- -%}
--
-- NOT used: include, capture, case/when, unless, raw, comment,
-- cycle, tablerow, increment, decrement, liquid, echo
}
invariant LiquidFilterSubset {
-- Standard filters (4):
-- | escape
-- | url_encode
-- | default: fallback_value
-- | append: suffix_string
--
-- Custom filters (2):
-- | 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)
--
-- 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 {
-- Comparison: ==, >
-- Logical: or, and
-- Truthy/falsy: bare variable in {% if variable %}
-- Special values: blank (nil/empty comparison)
-- Property access: dot notation (object.property), .size on arrays,
-- bracket notation for map lookups (map[key])
}
invariant LiquidRenderContext {
-- Template rendering context provides these top-level variables:
-- language, language_prefix, html_theme_attribute,
-- page_title, pico_stylesheet_href,
-- blog_languages (array of {is_current, code, flag, href_prefix}),
-- alternate_links (array of {hreflang, href}),
-- menu_items (tree of {href, title, has_children, children}),
-- calendar_initial_year, calendar_initial_month,
-- post (single post context: {title, content, id, slug, show_title}),
-- post_categories, post_tags, tag_color_by_name (map),
-- backlinks (array of {path, display_slug}),
-- day_blocks (array of {show_date_marker, date_label, posts, show_separator}),
-- archive_context ({kind, name, month, year, day}),
-- show_archive_range_heading, min_date, max_date,
-- canonical_post_path_by_slug (map), canonical_media_path_by_source_path (map),
-- post_data_json_by_id (map),
-- is_list_page, is_first_page, is_last_page,
-- has_prev_page, has_next_page, prev_page_href, next_page_href,
-- not_found_message, not_found_back_label
}

View File

@@ -0,0 +1,308 @@
-- allium: 1
-- bDS Liquid Template Context Specification
-- Scope: core (Wave 4 — rendering parity)
-- Distilled from: ../bDS/src/main/engine/GenerationRouteRendererFactory.ts,
-- PageRenderer.ts, BlogGenerationEngine.ts
--
-- This document specifies the exact data structure passed to Liquid templates
-- during rendering. It is the contract between the generation engine and
-- template authors.
-- ============================================================================
-- GLOBAL TEMPLATE VARIABLES
-- ============================================================================
surface TemplateRenderingSurface {
facing _: RenderPipeline
provides:
RenderPostPageRequested(post, language)
RenderListPageRequested(posts, pagination, archive_context)
RenderNotFoundPageRequested()
}
surface RenderContextSurface {
context context: RenderContext
exposes:
context.language
context.language_prefix
context.page_title
context.pico_stylesheet_href
context.blog_languages
context.alternate_links
context.menu_items
context.post
context.day_blocks
context.archive_context
context.is_list_page
context.prev_page_href
context.next_page_href
context.not_found_message
}
surface PaginationContextSurface {
context pagination: PaginationContext
exposes:
pagination.is_first_page
pagination.is_last_page
pagination.has_prev_page
pagination.has_next_page
pagination.prev_page_href
pagination.next_page_href
pagination.current_page
pagination.total_pages
pagination.total_items
pagination.items_per_page
}
value RenderContext {
-- Top-level variables available in all templates
language: String -- Current language code
language_prefix: String? -- "/de" or "" depending on language
html_theme_attribute: String -- Theme class for <html> element
page_title: String -- Page title for <title> tag
pico_stylesheet_href: String -- Path to Pico CSS theme
blog_languages: List<BlogLanguage>
alternate_links: List<AlternateLink>
menu_items: List<MenuItem>
calendar_initial_year: Integer
calendar_initial_month: Integer
post: PostContext? -- Present on single post pages
post_categories: List<Category>
post_tags: List<Tag>
tag_color_by_name: Map<String, String>
backlinks: List<Backlink>
day_blocks: List<DayBlock>
archive_context: ArchiveContext?
show_archive_range_heading: Boolean
min_date: Timestamp?
max_date: Timestamp?
is_list_page: Boolean
is_first_page: Boolean
is_last_page: Boolean
has_prev_page: Boolean
has_next_page: Boolean
prev_page_href: String?
next_page_href: String?
not_found_message: String?
not_found_back_label: String?
-- Lookup maps for macro expansion
canonical_post_path_by_slug: Map<String, String>
canonical_media_path_by_source_path: Map<String, String>
post_data_json_by_id: Map<String, PostDataJson>
}
value BlogLanguage {
is_current: Boolean
code: String
flag: String
href: String
href_prefix: String
}
value AlternateLink {
href: String
hreflang: String
}
value MenuItem {
href: String
title: String
has_children: Boolean
children: List<MenuItem>?
}
-- ============================================================================
-- POST CONTEXT (Single Post Pages)
-- ============================================================================
value PostContext {
id: String
title: String
content: String -- Rendered HTML (after markdown + macros)
slug: String
excerpt: String?
author: String?
language: String?
show_title: Boolean -- Always true for post pages
published_at: Timestamp
created_at: Timestamp
updated_at: Timestamp
tags: List<String>
categories: List<String>
template_slug: String?
do_not_translate: Boolean
linked_media: List<MediaContext>
outgoing_links: List<LinkContext>
incoming_links: List<LinkContext>
}
value PostDataJson {
-- Serialized post data for Liquid template access
id: String
title: String
slug: String
excerpt: String?
author: String?
language: String?
published_at: Timestamp
created_at: Timestamp
updated_at: Timestamp
tags: List<String>
categories: List<String>
}
value MediaContext {
id: String
filename: String
original_name: String
mime_type: String
title: String?
alt: String?
caption: String?
author: String?
width: Integer?
height: Integer?
file_path: String
}
value LinkContext {
href: String
title: String
display_slug: String
}
-- ============================================================================
-- ARCHIVE CONTEXT (List Pages)
-- ============================================================================
value ArchiveContext {
kind: category | tag | date
name: String?
month: Integer?
year: Integer?
day: Integer?
}
value DayBlock {
show_date_marker: Boolean
date_label: String
posts: List<PostContext>
show_separator: Boolean
}
value Category {
name: String
slug: String
post_count: Integer
}
value Tag {
name: String
slug: String
color: String?
post_count: Integer
}
value Backlink {
path: String
display_slug: String
title: String
}
-- ============================================================================
-- PAGINATION CONTEXT
-- ============================================================================
value PaginationContext {
is_list_page: Boolean
is_first_page: Boolean
is_last_page: Boolean
has_prev_page: Boolean
has_next_page: Boolean
prev_page_href: String?
next_page_href: String?
current_page: Integer
total_pages: Integer
total_items: Integer
items_per_page: Integer
}
-- ============================================================================
-- LIQUID FILTER SPECIFICATION
-- Note: Allium does not have a 'filter' keyword. Filters are documented here
-- for reference but are implemented in the template engine, not in Allium specs.
-- ============================================================================
-- Built-in filters:
-- default: {{ value | default: "fallback" }}
-- escape: {{ text | escape }}
-- url_encode: {{ text | url_encode }}
-- append: {{ text | append: suffix }}
--
-- Custom filters:
-- i18n: {{ "key" | i18n: language }} - translation lookup
-- markdown: {{ content | markdown: ... }} - markdown rendering with macro expansion
-- ============================================================================
-- BUILT-IN MACROS
-- Note: Allium does not have a 'macro' keyword. Macros are documented here
-- for reference but are implemented by the rendering subsystem.
-- ============================================================================
-- Built-in macros:
-- gallery: [[gallery images=post.linked_media columns=3]]
-- youtube: [[youtube id=dQw4w9WgXcQ]]
-- vimeo: [[vimeo id=123456789]]
-- photo_archive: [[photo_archive media=project.media]]
-- tag_cloud: [[tag_cloud tags=Tags]]
-- ============================================================================
-- TEMPLATE LOOKUP RULES
-- ============================================================================
invariant TemplateLookupPriority {
-- Templates are resolved in this order:
-- 1. Post-specific template (post.template_slug)
-- 2. Tag-specific template (tag.post_template_slug)
-- 3. Category-specific template (category.post_template_slug)
-- 4. Default "post" template
--
-- List pages use "list" template
-- 404 pages use "not_found" template
-- Partials are referenced via {% render 'partial' %}
}
invariant PartialResolution {
-- Partials are looked up in templates/ directory
-- Usage: {% render 'partials/header', context_var: value %}
-- Partial files: templates/partials/{name}.liquid
}
-- ============================================================================
-- RENDERING RULES
-- ============================================================================
rule RenderPostPage {
when: RenderPostPageRequested(post, language)
let template = resolve_template(post)
let context = BuildPostContext(post, language)
ensures: RenderedHtml = liquid_render(template.content, context)
}
rule RenderListPage {
when: RenderListPageRequested(posts, pagination, archive_context)
let template = first (t in Templates where t.kind = "list" and t.enabled)
let context = BuildListContext(posts, pagination, archive_context)
ensures: RenderedHtml = liquid_render(template.content, context)
}
rule RenderNotFoundPage {
when: RenderNotFoundPageRequested()
let template = first (t in Templates where t.kind = "not_found" and t.enabled)
let context = BuildNotFoundContext
ensures: RenderedHtml = liquid_render(template.content, context)
}

171
specs/translation.allium Normal file
View File

@@ -0,0 +1,171 @@
-- allium: 1
-- bDS Translation System
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/PostEngine.ts (translation methods),
-- postTranslationFileUtils.ts, MediaEngine.ts
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)
TranslationEdited(translation, changes)
}
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
title: String
excerpt: String?
content: String?
status: draft | published
file_path: String
checksum: String?
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
-- Derived
content_location: if status = published: file_path else: content
transitions status {
draft -> published
published -> draft
}
}
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:
(a != b and a.canonical_post = b.canonical_post)
implies a.language != b.language
}
invariant TranslationFilePath {
-- posts/YYYY/MM/{slug}.{language}.md
for t in PostTranslations where file_path != "":
t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md",
yyyy: t.canonical_post.created_at.year,
mm: t.canonical_post.created_at.month_padded,
slug: t.canonical_post.slug,
lang: t.language.code)
}
rule UpsertPostTranslation {
when: UpsertPostTranslationRequested(post, language, title, content, excerpt)
requires: not post.do_not_translate
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
}
rule PublishPostTranslation {
when: PostPublished(post)
-- All translations are also published when the canonical post is published
for t in post.translations:
ensures: PublishTranslationRequested(t)
}
rule PublishTranslation {
when: PublishTranslationRequested(translation)
requires: translation.status = draft
ensures: translation.status = published
ensures: translation.published_at = translation.published_at ?? now
ensures: TranslationFileWritten(translation)
ensures: translation.content = null
-- Content moves to filesystem
}
rule ReopenPublishedTranslation {
when: TranslationEdited(translation, changes)
requires: translation.status = published
requires: translation_edit_affects_published_content(changes)
ensures: translation.status = draft
ensures: translation.updated_at = now
}
rule DeletePostTranslation {
when: DeletePostTranslationRequested(translation)
ensures: not exists translation
ensures: TranslationFileDeleted(translation)
ensures: SearchIndexUpdated(translation.canonical_post)
-- FTS index includes all translations of a post
}
rule ValidateTranslations {
when: ValidateTranslationsRequested(project)
-- Checks all posts against configured blog languages
-- Reports: missing translations, orphan translation files,
-- posts marked do_not_translate
for post in project.posts where status = published:
for lang in project.blog_languages:
if lang != project.main_language:
if not post.do_not_translate:
if not (lang in post.available_languages):
ensures: ValidationIssueReported(post, lang, "missing")
@guidance
-- This produces a validation report, not automatic fixes
-- The report drives targeted re-rendering
}
invariant FtsIncludesTranslations {
-- Full-text search index for a post includes stemmed content
-- from all its translations, not just the canonical language
for post in Posts:
includes_text(search_index(post), post.title)
for t in post.translations:
includes_text(search_index(post), t.title)
}

203
specs/ui_data_flow.allium Normal file
View File

@@ -0,0 +1,203 @@
-- allium: 1
-- bDS UI Data Flow Model
-- Scope: cross-cutting (all waves)
-- Distilled from: appStore.ts, PostEditor.tsx, MediaEditor.tsx, Editor.tsx,
-- Sidebar.tsx, NotificationWatcher.ts
-- Describes the reactive data flows that connect sidebar, editor, tab bar,
-- and backend engine operations. Covers: sidebar->editor, editor->sidebar,
-- cross-tab coordination, and backend->UI event propagation.
use "./layout.allium" as layout
use "./tabs.allium" as tabs
use "./sidebar_views.allium" as sidebar
-- ─── External surfaces ──────────────────────────────────────
-- User-initiated navigation and entity management actions.
-- These triggers originate from sidebar clicks, tab operations,
-- dashboard interactions, and save/delete actions in editors.
surface UserNavigation {
provides: SidebarItemClicked(entity_type, entity_id, click_type)
provides: SidebarCreateRequested(entity_type)
provides: SidebarDeleteRequested(entity_type, entity_id)
provides: DashboardPostClicked(post_id, click_type)
provides: PostSaved(post_id, updated_post)
provides: PostStatusTransitioned(post_id, old_status, new_status)
provides: PostDeletedFromEditor(post_id)
provides: MediaSavedFromEditor(media_id, updated_media)
provides: MediaDeletedFromEditor(media_id)
provides: SettingsRebuildCompleted(entity_type, new_data)
provides: TransientTabBeingReplaced(old_tab, new_tab)
provides: TabClosed(tab)
}
-- ─── 1. Sidebar -> Editor flows ──────────────────────────────
-- All UI coordination flows through shared application state.
-- There are no direct calls between sidebar and editor.
-- Both read from and write to the same state model; changes propagate
-- reactively.
rule SidebarEntityClick {
when: SidebarItemClicked(entity_type, entity_id, click_type)
-- Single click: open as transient/preview tab
-- Double click: open as pinned tab
-- See sidebar_views.allium for per-view click rules
-- Effect: Editor mounts the matching view keyed by entity_id
-- Effect: Sidebar item highlights based on activeTabId match
-- If a prior transient tab of same type exists, it is replaced,
-- and the old editor unmounts (triggering auto-save if dirty)
}
rule SidebarCreateEntity {
when: SidebarCreateRequested(entity_type)
-- Posts/Pages: backend creates post, emits post:created,
-- store adds to posts list, sidebar re-renders with new item.
-- Does NOT open a tab automatically. User must click to open.
-- Sets selectedPostId for highlighting, optionally ensures sidebar visible.
-- Scripts/Templates: backend creates, emits event, opens tab immediately.
-- Chat: creates conversation, opens as pinned tab.
-- Import: creates definition, opens as pinned tab.
}
rule SidebarDeleteEntity {
when: SidebarDeleteRequested(entity_type, entity_id)
-- Scripts/Templates/Chat/Import: backend deletes entity,
-- then closeTab(entity_id) removes its tab,
-- activates adjacent tab or shows dashboard.
-- Posts/Media: deletion is triggered from the EDITOR (not sidebar).
-- See editor_post.allium PostDeleteAction / editor_media.allium MediaDeleteAction.
}
invariant SidebarFilterIsolation {
-- Sidebar search/filter state is local to the sidebar component.
-- Filtering never affects: active tab, editor content, selectedPostId.
-- Only the visible list of items changes.
}
-- ─── 2. Editor -> Sidebar flows ──────────────────────────────
rule PostTitleChanged {
when: PostSaved(post_id, updated_post)
-- Auto-save fires after 3s idle, or on Ctrl+S, or on unmount/tab switch
-- Store's posts array is updated with new title/metadata
-- Sidebar PostsList re-renders reactively showing new title
-- TabBar re-renders showing new title
ensures: dirtyPosts.remove(post_id)
}
rule PostStatusChanged {
when: PostStatusTransitioned(post_id, old_status, new_status)
-- Store's posts array is updated with new status
-- Sidebar detects status change (compares prev/current status maps)
-- and re-runs search/filter if active
-- Post moves between draft/published/archived sections in sidebar
}
rule PostEditorDelete {
when: PostDeletedFromEditor(post_id)
ensures: store.removePost(post_id)
-- Removes from posts array, clears selectedPostId if matching,
-- removes from dirtyPosts
ensures: closeTab(post_id)
-- Sidebar re-renders without the post
}
rule MediaEditorSave {
when: MediaSavedFromEditor(media_id, updated_media)
ensures: store.updateMedia(media_id, updated_media)
-- Sidebar MediaList re-renders with updated metadata
}
rule MediaEditorDelete {
when: MediaDeletedFromEditor(media_id)
ensures: store.removeMedia(media_id)
-- Editor.tsx safety net: detects active media tab references
-- non-existent item, calls closeTab(activeTab.id)
-- Sidebar re-renders without the media item
}
rule SettingsRebuild {
when: SettingsRebuildCompleted(entity_type, new_data)
-- Wholesale replacement of posts or media array in store
ensures: store.setPosts(new_data) or store.setMedia(new_data)
-- Sidebar re-renders entirely with fresh data
}
-- ─── 3. Cross-tab coordination ──────────────────────────────
invariant TabSwitchDoesNotChangeSidebarView {
-- Switching tabs does NOT change activeView in the sidebar.
-- The sidebar view is controlled exclusively by the Activity Bar.
-- Exception: Dashboard post click explicitly sets activeView = "posts".
}
invariant SidebarHighlightFollowsActiveTab {
-- Sidebar item highlight is based on activeTabId === entity.id,
-- NOT on selectedPostId/selectedMediaId (which are separate concepts
-- used only for post creation flow).
}
rule TransientTabReplacement {
when: TransientTabBeingReplaced(old_tab, new_tab)
-- Old editor unmounts -> cleanup auto-save fires if dirty
-- New editor mounts with new entity
-- Sidebar highlight shifts to newly active entity
}
rule TabCloseCleanup {
when: TabClosed(tab)
-- If post tab: PostEditor unmounts, auto-save fires if dirty
-- Store activates adjacent tab (prefer right, then left, then null)
-- Sidebar highlight updates to new active tab
-- If no tabs remain: dashboard shown
}
rule DashboardPostClick {
when: DashboardPostClicked(post_id, click_type)
-- This is the ONLY place where opening a tab also switches sidebar view
ensures: setActiveView("posts")
ensures: setSelectedPost(post_id)
if click_type = single:
ensures: OpenTabRequested(type: post, id: post_id, intent: preview)
if click_type = double:
ensures: OpenTabRequested(type: post, id: post_id, intent: pin)
}
-- ─── 4. Backend -> UI event propagation ─────────────────────
-- Backend engines emit events via IPC. The renderer listens and updates
-- the shared store. Both sidebar and editor re-render reactively.
-- Events: post:created, post:updated, post:deleted,
-- media:imported, media:updated, media:deleted,
-- template:created/updated/deleted, script:created/updated/deleted,
-- entity:changed (from CLI/MCP mutations via NotificationWatcher)
-- TabBar also listens directly for:
-- post-updated (title refresh)
-- bds:scripts-changed (script title refresh)
-- BDS_EVENT_TEMPLATES_CHANGED (template title refresh)
-- chat.onTitleUpdated (chat conversation title refresh)
-- importDefinitions.onNameUpdated (import name refresh)
-- Editor.tsx has safety-net useEffect guards that:
-- Close media tabs when referenced media no longer exists in store
-- Clear selectedPostId/selectedMediaId when entity is gone
-- ─── 5. Keyboard shortcut map ─────────────────────────────
-- All shortcuts use Cmd on macOS, Ctrl on other platforms.
-- Global:
-- Ctrl/Cmd+B: toggle sidebar visibility
-- Ctrl/Cmd+W: close active tab
-- Post editor:
-- Ctrl/Cmd+S: save post immediately (resets auto-save timer)
-- Ctrl/Cmd+K: open InsertModal (insert post link)
-- Sidebar lists:
-- Enter: open selected item as pinned tab
-- Space: open selected item as preview/transient tab