Files
bDS2/specs/tabs.allium
2026-04-23 10:42:27 +02:00

248 lines
9.7 KiB
Plaintext

-- 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.