-- 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 -- 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 -- 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 } 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 -- tags only, no categories for media grid: List -- 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 } 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='main', 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 } 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 } 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 } 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 history_entries: List 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 } 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 }