diff --git a/specs/bds.allium b/specs/bds.allium index d7aaabd..5818b16 100644 --- a/specs/bds.allium +++ b/specs/bds.allium @@ -26,6 +26,11 @@ 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 + -- Integration use "./git.allium" as git -- Git operations, LFS, reconciliation use "./mcp.allium" as mcp -- MCP server (tools, resources, proposals) diff --git a/specs/layout.allium b/specs/layout.allium new file mode 100644 index 0000000..f6a5a92 --- /dev/null +++ b/specs/layout.allium @@ -0,0 +1,252 @@ +-- 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 + +-- ─── 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 +} + +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 +} + +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 (5 groups: File, Edit, View, Blog, Help) + -- 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 + bottom_group: List +} + +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) diff --git a/specs/sidebar_views.allium b/specs/sidebar_views.allium new file mode 100644 index 0000000..b507f12 --- /dev/null +++ b/specs/sidebar_views.allium @@ -0,0 +1,352 @@ +-- 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 ───────────────────────────────────────── + +-- SidebarEntityList: reusable pattern for scripts, templates, chat, import. +-- Shows: header with title + create button, scrollable item list, +-- empty state with message + action CTA. + +-- Date formatting for sidebar items: +-- Today -> time only (e.g. "2:30 PM") +-- Yesterday -> i18n "Yesterday" +-- <7 days -> short weekday (e.g. "Mon") +-- Older -> "Mon DD" format +-- Locale map: en->en-US, de->de-DE, fr->fr-FR, it->it-IT, es->es-ES + +-- ─── 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 -- selected tags (multi-select chips with colours) + category_filter: List -- selected categories (multi-select chips) + draft_section: List + published_section: List + archived_section: List + has_more: Boolean -- pagination, 500 per batch +} + +-- 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 from categories (below) + title: String -- fallback "Untitled" + language_count: Integer? -- badge shown when availableLanguages.count > 1 + date: String -- drafts/archived: updatedAt; published: publishedAt ?? updatedAt + active: Boolean -- highlighted when activeTabId = post.id +} + +-- Post type icon derivation from categories (first match wins, case-insensitive): +-- picture/photo/image -> camera icon +-- aside/note/quick -> notepad icon +-- link/bookmark -> link icon +-- video -> film icon +-- quote -> speech bubble icon +-- default -> document icon + +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 -- tags only, no categories for media + grid: List -- grid layout (not list) +} + +value MediaGridItem { + media_id: String + has_thumbnail: Boolean -- image: custom protocol bds-thumb://{id}; non-image: file icon + name: String -- title truncated 60 chars, fallback originalName + file_size: String -- formatted (B / KB / MB) + tooltip: String -- caption ?? originalName + active: Boolean -- highlighted when activeTabId = media.id +} + +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 ────────────────────────────────────────── + +-- Uses SidebarEntityList pattern. + +value ScriptsView { + items: List +} + +value ScriptListItem { + script_id: String + title: String + date: String -- relative date format + active: Boolean +} + +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) +} + +-- Keyboard: Enter = pin, Space = preview. +-- Delete button: deletes script and closes its tab. +-- Create defaults: kind=utility, content='print("new script")', entrypoint='render', enabled=true. +-- Refreshes on scripts-changed event. + +-- ─── 5. Templates view ─────────────────────────────────────── + +-- Uses SidebarEntityList pattern. Same as scripts with: + +value TemplatesView { + items: List +} + +value TemplateListItem { + template_id: String + title: String + date: String + active: Boolean +} + +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) +} + +-- Delete: if referenced by posts or tags, shows confirmation with counts before force-delete. +-- Create defaults: kind=post, content='', enabled=true. +-- Refreshes on templates-changed event. + +-- ─── 6. Settings view ──────────────────────────────────────── + +-- Navigation list that controls sections within the settings editor tab. +-- 9 sections in order: +-- 1. project (folder icon) +-- 2. editor (notepad icon) +-- 3. content (clipboard icon) +-- 4. ai (robot icon) +-- 5. technology (gear icon) +-- 6. publishing (rocket icon) +-- 7. data (database icon) +-- 8. mcp (plug icon) +-- 9. style (palette icon) -- special: opens style tab, not settings section + +value SettingsNav { + active_section: String? -- persisted across sidebar switches +} + +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. +-- 3 sections: +-- 1. cloud (cloud icon) -- tag cloud visualization +-- 2. manage (pencil icon) -- create/edit tags +-- 3. merge (merge icon) -- merge duplicate tags + +value TagsNav { + active_section: String? -- persisted +} + +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 ───────────────────────────────────────────── + +-- Uses SidebarEntityList pattern. + +value ChatView { + api_ready: Boolean -- shows API key prompt if false + items: List +} + +value ChatListItem { + conversation_id: String + title: String -- live-updated via onTitleUpdated events + date: String -- relative date format +} + +rule ChatListClick { + when: ChatListItemClicked(conversation_id) + ensures: OpenTabRequested(type: chat, id: conversation_id, intent: pin) + -- Chat tabs are always pinned +} + +-- Delete button: deletes conversation and closes its tab. + +-- ─── 9. Import view ────────────────────────────────────────── + +-- Uses SidebarEntityList pattern. + +value ImportView { + items: List +} + +value ImportListItem { + definition_id: String + name: String -- live-updated via onNameUpdated events + date: String +} + +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 + history_entries: List + has_more_history: Boolean -- paginated, 20 per page +} + +value GitStatusFile { + path: String + status: String -- modified, added, deleted, renamed, etc. +} + +value GitHistoryEntry { + short_hash: String + subject: String + author: String + date: String -- locale-formatted + sync_status: String -- synced, local_only, remote_only +} + +-- 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 +} + +value CalendarMonth { + month: Integer -- 1-12 + count: Integer -- number of items in this month +} diff --git a/specs/tabs.allium b/specs/tabs.allium new file mode 100644 index 0000000..2d4fc1a --- /dev/null +++ b/specs/tabs.allium @@ -0,0 +1,198 @@ +-- 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 + +-- ─── 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) +} + +-- ─── 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). + +value TabBarItem { + title: String + is_active: Boolean + is_transient: Boolean -- italic title when true + is_dirty: Boolean -- dot indicator, only for post tabs +} + +-- Tab title resolution: +-- post: post.title from DB (listens post-updated events) +-- media: media.originalName +-- scripts: script.title from DB (listens scripts-changed) +-- templates: template.title from DB (listens templates-changed) +-- chat: conversation.title, truncated to 18 chars +-- import: definition.name (listens name-updated) +-- git_diff file: filename from path (last path segment) +-- git_diff commit: "{shortHash} {subject}" from git history +-- singletons: i18n key lookup (common.settings, tabBar.style, etc.) +-- fallback: i18n:tabBar.unknown + +-- ─── 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.