personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Phase 0 app plugin system foundation for Convey

Implement modular app architecture to support gradual migration from
monolithic view system to plugin-based design. Both systems coexist
during transition.

Infrastructure:
- apps/__init__.py: BaseApp interface and AppRegistry for discovery
- apps/home/: reference implementation with routes.py pattern
- convey/templates/app.html: unified template with facet-bar, menu-bar,
status-pane, and responsive facet pills
- Context processors for facet data and app registry injection

Configuration:
- Add apps* to pyproject.toml package discovery
- Apps use standardized structure: __init__.py, routes.py, templates/

Routes:
- Legacy: / (convey/views/)
- New: /app/{name}/ (apps/ plugins)

Updated DESIGN.md with implementation status and file structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+1115 -6
+197
apps/__init__.py
··· 1 + """App plugin system for Sunstone. 2 + 3 + Apps are self-contained modules that can extend any part of the system: 4 + - routes.py: Convey web routes and handlers 5 + - templates/: Jinja2 templates for web views 6 + - agents/: Muse agent prompts (future) 7 + - topics/: Think topic templates (future) 8 + - tasks/: Background task definitions (future) 9 + 10 + Each app provides a Flask blueprint for web routes and metadata for 11 + navigation. Apps are automatically discovered and registered. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import importlib 17 + import logging 18 + from abc import ABC, abstractmethod 19 + from pathlib import Path 20 + from typing import Optional 21 + 22 + from flask import Blueprint 23 + 24 + logger = logging.getLogger(__name__) 25 + 26 + 27 + class BaseApp(ABC): 28 + """Base class for all Sunstone apps. 29 + 30 + Apps must inherit from this class and implement the required methods. 31 + All apps automatically get facet integration (facet pills, selected_facet 32 + context, and facet cookie handling). 33 + """ 34 + 35 + # Required metadata - subclasses must set these 36 + name: str 37 + icon: str 38 + label: str 39 + 40 + @abstractmethod 41 + def get_blueprint(self) -> Blueprint: 42 + """Return Flask Blueprint with app routes. 43 + 44 + The blueprint should handle all routing for the app. Typically, 45 + it will have at least one route that renders app.html with 46 + app=self.name. 47 + 48 + Returns: 49 + Flask Blueprint instance with routes registered 50 + """ 51 + pass 52 + 53 + def get_workspace_template(self) -> str: 54 + """Return path to workspace template. 55 + 56 + This template is included in the main content area and should 57 + contain the primary UI for the app. 58 + 59 + Returns: 60 + Template path relative to Flask template directory 61 + Default: apps/{name}/templates/workspace.html 62 + """ 63 + return f"apps/{self.name}/templates/workspace.html" 64 + 65 + def get_app_bar_template(self) -> Optional[str]: 66 + """Return path to custom app-bar template, or None for empty. 67 + 68 + The app-bar is the bottom fixed bar where apps can place actions, 69 + inputs, controls, etc. Return None to leave it empty. 70 + 71 + Returns: 72 + Template path or None for no app-bar 73 + Default: None (empty app-bar) 74 + """ 75 + return None 76 + 77 + def get_submenu_items( 78 + self, facets: list[dict], selected_facet: Optional[str] = None 79 + ) -> list[dict]: 80 + """Return submenu items for the menu-bar. 81 + 82 + Fully custom implementation per app. Apps can create submenu items 83 + based on facets, dates, static lists, or any other pattern. 84 + 85 + Args: 86 + facets: List of active facet dicts with name, title, color, emoji 87 + selected_facet: Currently selected facet name, or None 88 + 89 + Returns: 90 + List of dicts with: 91 + - label: Display text 92 + - path: URL path 93 + - count: Optional badge count (int) 94 + - facet: Optional facet name for data-facet attribute 95 + 96 + Example: 97 + [ 98 + {"label": "Personal", "path": "/app/todos", "facet": "personal", "count": 7}, 99 + {"label": "Work", "path": "/app/todos", "facet": "work", "count": 5}, 100 + ] 101 + """ 102 + return [] 103 + 104 + def get_facet_counts( 105 + self, facets: list[dict], selected_facet: Optional[str] = None 106 + ) -> dict[str, int]: 107 + """Optional: Return badge counts for facet pills. 108 + 109 + Only implement if app wants to show counts on facet pills. 110 + Leave unimplemented or return empty dict to show no counts. 111 + 112 + Args: 113 + facets: List of active facet dicts 114 + selected_facet: Currently selected facet name, or None 115 + 116 + Returns: 117 + Dict mapping facet name to count, e.g.: 118 + {"work": 5, "personal": 3, "acme": 12} 119 + """ 120 + return {} 121 + 122 + 123 + class AppRegistry: 124 + """Registry for discovering and managing Sunstone apps.""" 125 + 126 + def __init__(self): 127 + self.apps: dict[str, BaseApp] = {} 128 + 129 + def discover(self) -> None: 130 + """Auto-discover apps in apps/ directory. 131 + 132 + Scans the apps/ directory for subdirectories and attempts to import 133 + and instantiate an App class from each one. Apps must follow the 134 + naming convention: {name}App (e.g., TodosApp, InboxApp, HomeApp). 135 + 136 + The app module should be at apps/{name}/__init__.py and contain 137 + a class named {Name}App that inherits from BaseApp. 138 + """ 139 + apps_dir = Path(__file__).parent 140 + 141 + for app_path in sorted(apps_dir.iterdir()): 142 + # Skip non-directories and private/internal directories 143 + if not app_path.is_dir() or app_path.name.startswith("_"): 144 + continue 145 + 146 + app_name = app_path.name 147 + 148 + # Skip if __init__.py doesn't exist 149 + if not (app_path / "__init__.py").exists(): 150 + continue 151 + 152 + try: 153 + # Import app module 154 + module = importlib.import_module(f"apps.{app_name}") 155 + 156 + # Find App class (e.g., TodosApp, InboxApp, HomeApp) 157 + # Convert kebab-case or snake_case to PascalCase 158 + app_class_name = f"{app_name.title().replace('_', '')}App" 159 + 160 + if hasattr(module, app_class_name): 161 + app_class = getattr(module, app_class_name) 162 + app_instance = app_class() 163 + 164 + # Validate it's a BaseApp subclass 165 + if not isinstance(app_instance, BaseApp): 166 + logger.warning( 167 + f"App {app_class_name} is not a BaseApp subclass, skipping" 168 + ) 169 + continue 170 + 171 + # Register the app 172 + self.apps[app_instance.name] = app_instance 173 + logger.info(f"Discovered app: {app_instance.name}") 174 + else: 175 + logger.debug( 176 + f"App directory {app_name}/ missing {app_class_name} class, skipping" 177 + ) 178 + 179 + except Exception as e: 180 + logger.error(f"Failed to load app {app_name}: {e}", exc_info=True) 181 + 182 + def register_blueprints(self, flask_app) -> None: 183 + """Register all app blueprints with Flask. 184 + 185 + Args: 186 + flask_app: Flask application instance 187 + """ 188 + for app in self.apps.values(): 189 + try: 190 + blueprint = app.get_blueprint() 191 + flask_app.register_blueprint(blueprint) 192 + logger.info(f"Registered blueprint: {blueprint.name}") 193 + except Exception as e: 194 + logger.error( 195 + f"Failed to register blueprint for app {app.name}: {e}", 196 + exc_info=True, 197 + )
+21
apps/home/__init__.py
··· 1 + """Home app - main dashboard and overview.""" 2 + 3 + from __future__ import annotations 4 + 5 + from apps import BaseApp 6 + 7 + 8 + class HomeApp(BaseApp): 9 + """Home app implementation.""" 10 + 11 + name = "home" 12 + icon = "🏠" 13 + label = "Home" 14 + 15 + def get_blueprint(self): 16 + from .routes import home_bp 17 + 18 + return home_bp 19 + 20 + def get_workspace_template(self): 21 + return "workspace.html"
+18
apps/home/routes.py
··· 1 + """Home app routes and handlers.""" 2 + 3 + from __future__ import annotations 4 + 5 + from flask import Blueprint, render_template 6 + 7 + home_bp = Blueprint( 8 + "home_app", 9 + __name__, 10 + template_folder="templates", 11 + url_prefix="/app/home", 12 + ) 13 + 14 + 15 + @home_bp.route("/") 16 + def index(): 17 + """Home main view.""" 18 + return render_template("app.html", app="home")
+35
apps/home/templates/workspace.html
··· 1 + <div class="content"> 2 + <h1>Home Dashboard</h1> 3 + <p>Welcome to Sunstone! This is the new app-based navigation system.</p> 4 + 5 + <div class="placeholder"> 6 + <p><em>Dashboard content will be migrated here in Phase 1.</em></p> 7 + </div> 8 + </div> 9 + 10 + <style> 11 + .content { 12 + max-width: 1400px; 13 + margin: 0 auto; 14 + padding: 2em; 15 + } 16 + 17 + h1 { 18 + font-size: 2.5em; 19 + margin-bottom: 0.5em; 20 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 21 + -webkit-background-clip: text; 22 + -webkit-text-fill-color: transparent; 23 + background-clip: text; 24 + } 25 + 26 + .placeholder { 27 + background: white; 28 + border-radius: 12px; 29 + padding: 2em; 30 + box-shadow: 0 4px 15px rgba(0,0,0,0.08); 31 + margin-top: 2em; 32 + text-align: center; 33 + color: #666; 34 + } 35 + </style>
+40 -4
convey/DESIGN.md
··· 13 13 14 14 ## App System 15 15 16 - Apps are dynamically discovered from the `apps/` directory. Each app is a Python module implementing the `BaseApp` interface. 16 + Apps are dynamically discovered from the top-level `apps/` directory. Each app is a Python module implementing the `BaseApp` interface. 17 17 18 - **App Structure** 18 + **App File Structure** 19 + ``` 20 + apps/{name}/ 21 + ├── __init__.py # App class with metadata 22 + ├── routes.py # Flask blueprint and route handlers 23 + └── templates/ 24 + ├── workspace.html # Main content (required) 25 + └── app_bar.html # Bottom bar controls (optional) 26 + ``` 27 + 28 + **App Metadata** 19 29 - Each app provides an icon, label, Flask blueprint, and workspace template 20 30 - Apps optionally define submenu items, facet counts, and custom app-bar template 21 31 - Apps automatically receive facet integration (facet pills and selection state) 22 32 23 33 **App Methods** 24 - - `get_blueprint()` - Flask routes for the app 25 - - `get_workspace_template()` - Main content template path 34 + - `get_blueprint()` - Flask routes for the app (from routes.py) 35 + - `get_workspace_template()` - Main content template path (default: relative to blueprint template_folder) 26 36 - `get_app_bar_template()` - Optional bottom bar template (return None to hide app-bar) 27 37 - `get_submenu_items()` - Optional submenu with custom logic per app 28 38 - `get_facet_counts()` - Optional badge counts for facet pills ··· 116 126 **Responsive Facet Pills** 117 127 - Pills collapse to icon-only from right-to-left when space constrained 118 128 - Uses ResizeObserver to detect container width changes 129 + 130 + --- 131 + 132 + ## Implementation Status 133 + 134 + ### Phase 0: Foundation (Complete) 135 + 136 + **Infrastructure Created:** 137 + - `apps/__init__.py` - BaseApp class and AppRegistry for plugin discovery 138 + - `convey/templates/app.html` - Main app container template with all UI components 139 + - Context processors for facet data and app registry injection 140 + - Package configuration in `pyproject.toml` to include apps package 141 + 142 + **Example App:** 143 + - `apps/home/` - Reference implementation demonstrating the pattern 144 + - Routes at `/app/home/` (new system) vs `/` (legacy system) 145 + - Both systems coexist during migration 146 + 147 + **Routes:** 148 + - Legacy views: `/`, `/facets`, `/calendar`, etc. (existing `convey/views/`) 149 + - New apps: `/app/{name}/` (plugin system via `apps/`) 150 + 151 + **Next Steps:** 152 + - Migrate existing views to apps one at a time 153 + - Each app follows the standardized structure with routes.py and templates 154 + - Legacy views can be deprecated after all apps are migrated
+58 -1
convey/__init__.py
··· 11 11 from importlib import import_module 12 12 from typing import Callable 13 13 14 - from flask import Flask 14 + from flask import Flask, request 15 15 from flask_sock import Sock 16 16 17 + from apps import AppRegistry 17 18 from think import messages as message_store 18 19 from think import todo as todo_store 19 20 from think.utils import setup_cli ··· 109 110 return badges 110 111 111 112 113 + def _get_facets_data() -> list[dict]: 114 + """Get facets data for templates.""" 115 + from think.facets import get_facets 116 + 117 + all_facets = get_facets() 118 + active_facets = [] 119 + 120 + for name, data in all_facets.items(): 121 + if not data.get("disabled", False): 122 + active_facets.append( 123 + { 124 + "name": name, 125 + "title": data.get("title", name), 126 + "color": data.get("color", ""), 127 + "emoji": data.get("emoji", ""), 128 + } 129 + ) 130 + 131 + return active_facets 132 + 133 + 134 + def _get_selected_facet() -> str | None: 135 + """Get the currently selected facet from cookie.""" 136 + return request.cookies.get("selectedFacet") 137 + 138 + 112 139 def create_app(journal: str = "") -> Flask: 113 140 """Create and configure the review Flask application.""" 114 141 app = Flask( ··· 118 145 ) 119 146 app.secret_key = os.getenv("CONVEY_SECRET", "sunstone-secret") 120 147 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) 148 + 149 + # Register legacy views 121 150 register_views(app) 122 151 152 + # Initialize and register app system 153 + registry = AppRegistry() 154 + registry.discover() 155 + registry.register_blueprints(app) 156 + 123 157 @app.context_processor 124 158 def inject_nav_badges() -> dict[str, dict[str, int]]: 125 159 """Expose nav badge counts to all templates.""" 126 160 return {"nav_badges": _resolve_nav_badges()} 161 + 162 + @app.context_processor 163 + def inject_app_context() -> dict: 164 + """Inject app registry and facets context for new app system.""" 165 + facets = _get_facets_data() 166 + selected_facet = _get_selected_facet() 167 + 168 + # Build apps dict for menu-bar (includes submenu items) 169 + apps_dict = {} 170 + for app_instance in registry.apps.values(): 171 + submenu = app_instance.get_submenu_items(facets, selected_facet) 172 + apps_dict[app_instance.name] = { 173 + "icon": app_instance.icon, 174 + "label": app_instance.label, 175 + "submenu": submenu if submenu else None, 176 + } 177 + 178 + return { 179 + "app_registry": registry, 180 + "apps": apps_dict, 181 + "facets": facets, 182 + "selected_facet": selected_facet, 183 + } 127 184 128 185 sock = Sock(app) 129 186 register_websocket(sock)
+744
convey/templates/app.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 + <title>{{ app_registry.apps[app].label }} - Sunstone</title> 7 + <link rel="stylesheet" href="{{ url_for('review.static', filename='review.css') }}"> 8 + 9 + <!-- Embed facets data for immediate client-side access --> 10 + <script> 11 + window.facetsData = {{ facets|tojson|safe }}; 12 + window.selectedFacetFromServer = {{ selected_facet|tojson|safe }}; 13 + </script> 14 + 15 + <!-- Apply facet theme immediately to prevent flash --> 16 + {% if selected_facet %} 17 + {% set selected_facet_data = facets|selectattr("name", "equalto", selected_facet)|first %} 18 + {% if selected_facet_data and selected_facet_data.color %} 19 + <style> 20 + :root { 21 + --facet-color: {{ selected_facet_data.color }}; 22 + --facet-bg: {{ selected_facet_data.color }}1a; /* 10% opacity */ 23 + --facet-border: {{ selected_facet_data.color }}; 24 + } 25 + </style> 26 + {% endif %} 27 + {% endif %} 28 + 29 + <style> 30 + body { 31 + margin: 0; 32 + padding: 0; 33 + overflow-x: hidden; 34 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 35 + } 36 + 37 + .container { 38 + padding: 0 1em; 39 + margin-top: 60px; 40 + margin-bottom: 1em; 41 + margin-left: 1em; 42 + } 43 + 44 + /* Add bottom margin when app-bar is present */ 45 + body.has-app-bar .container { 46 + margin-bottom: 70px; 47 + } 48 + 49 + /* Facet Bar (top) */ 50 + .facet-bar { 51 + position: fixed; 52 + top: 0; 53 + left: 0; 54 + right: 0; 55 + background: white; 56 + border-bottom: 1px solid var(--facet-border, #e0e0e0); 57 + z-index: 1000; 58 + height: 60px; 59 + padding: 12px 16px; 60 + transition: border-bottom-color 0.3s ease; 61 + overflow: visible; 62 + white-space: nowrap; 63 + display: flex; 64 + align-items: center; 65 + gap: 12px; 66 + } 67 + 68 + .facet-bar::before { 69 + content: ''; 70 + position: absolute; 71 + top: 0; 72 + left: 0; 73 + right: 0; 74 + bottom: 0; 75 + background: var(--facet-bg, transparent); 76 + transition: background-color 0.3s ease; 77 + pointer-events: none; 78 + z-index: -1; 79 + } 80 + 81 + .facet-bar #hamburger { 82 + font-size: 24px; 83 + cursor: pointer; 84 + padding: 8px; 85 + border-radius: 4px; 86 + transition: background 0.2s; 87 + user-select: none; 88 + flex-shrink: 0; 89 + } 90 + 91 + .facet-bar #hamburger:hover { 92 + background: rgba(0,0,0,0.05); 93 + } 94 + 95 + .facet-bar .app-icon { 96 + font-size: 28px; 97 + padding: 4px; 98 + flex-shrink: 0; 99 + cursor: pointer; 100 + border-radius: 4px; 101 + transition: background 0.2s; 102 + text-decoration: none; 103 + } 104 + 105 + .facet-bar .app-icon:hover { 106 + background: rgba(0,0,0,0.05); 107 + } 108 + 109 + .facet-bar .status-icon { 110 + font-size: 20px; 111 + padding: 4px; 112 + flex-shrink: 0; 113 + cursor: pointer; 114 + border-radius: 4px; 115 + transition: background 0.2s; 116 + margin-left: auto; 117 + position: relative; 118 + } 119 + 120 + .facet-bar .status-icon:hover { 121 + background: rgba(0,0,0,0.05); 122 + } 123 + 124 + /* Status Pane */ 125 + .status-pane { 126 + position: fixed; 127 + top: calc(60px + 4px); 128 + right: 16px; 129 + background: white; 130 + border: 1px solid #e0e0e0; 131 + border-radius: 8px; 132 + box-shadow: 0 4px 12px rgba(0,0,0,0.15); 133 + min-width: 280px; 134 + max-width: 400px; 135 + display: none; 136 + z-index: 10000; 137 + } 138 + 139 + .status-pane.visible { 140 + display: block; 141 + } 142 + 143 + .status-pane-content { 144 + padding: 16px; 145 + } 146 + 147 + .status-pane-content h3 { 148 + margin: 0 0 12px 0; 149 + font-size: 16px; 150 + font-weight: 600; 151 + color: #333; 152 + } 153 + 154 + .status-pane-content p { 155 + margin: 0; 156 + font-size: 14px; 157 + color: #666; 158 + } 159 + 160 + .facet-bar .facet-pills-container { 161 + flex: 1; 162 + display: flex; 163 + align-items: center; 164 + justify-content: center; 165 + overflow-x: auto; 166 + overflow-y: visible; 167 + white-space: nowrap; 168 + scrollbar-width: thin; 169 + padding-top: 4px; 170 + padding-bottom: 4px; 171 + } 172 + 173 + .facet-bar .facet-pills-container::-webkit-scrollbar { 174 + height: 6px; 175 + } 176 + 177 + .facet-bar .facet-pills-container::-webkit-scrollbar-thumb { 178 + background: #ccc; 179 + border-radius: 3px; 180 + } 181 + 182 + /* Menu Bar (left sidebar) */ 183 + .menu-bar { 184 + position: fixed; 185 + top: 60px; 186 + left: -240px; 187 + width: 240px; 188 + bottom: 60px; 189 + background: white; 190 + border-right: 1px solid var(--facet-border, #e0e0e0); 191 + z-index: 999; 192 + transition: left 0.3s ease, border-right-color 0.3s ease; 193 + overflow-y: auto; 194 + } 195 + 196 + .menu-bar::before { 197 + content: ''; 198 + position: absolute; 199 + top: 0; 200 + left: 0; 201 + right: 0; 202 + bottom: 0; 203 + background: var(--facet-bg, transparent); 204 + transition: background-color 0.3s ease; 205 + pointer-events: none; 206 + z-index: -1; 207 + } 208 + 209 + body.sidebar-open .menu-bar { 210 + left: 0; 211 + } 212 + 213 + .menu-bar .menu-item { 214 + padding: 8px 16px; 215 + display: flex; 216 + align-items: center; 217 + gap: 10px; 218 + cursor: pointer; 219 + transition: background 0.2s; 220 + border-bottom: none; 221 + text-decoration: none; 222 + color: inherit; 223 + } 224 + 225 + .menu-bar .menu-item:hover { 226 + background: rgba(102, 126, 234, 0.1); 227 + } 228 + 229 + .menu-bar .menu-item.current { 230 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 231 + color: white; 232 + font-weight: 500; 233 + } 234 + 235 + .menu-bar .menu-item .icon { 236 + font-size: 20px; 237 + width: 24px; 238 + text-align: center; 239 + } 240 + 241 + .menu-bar .menu-item .label { 242 + font-size: 14px; 243 + } 244 + 245 + .menu-bar .submenu { 246 + display: flex; 247 + flex-direction: column; 248 + background: rgba(0,0,0,0.03); 249 + border-left: 2px solid rgba(102, 126, 234, 0.3); 250 + } 251 + 252 + .menu-bar .submenu-item { 253 + display: flex; 254 + align-items: center; 255 + justify-content: space-between; 256 + padding: 6px 16px 6px 44px; 257 + font-size: 13px; 258 + color: inherit; 259 + text-decoration: none; 260 + transition: background 0.2s; 261 + border-bottom: none; 262 + } 263 + 264 + .menu-bar .submenu-item:hover { 265 + background: rgba(102, 126, 234, 0.1); 266 + } 267 + 268 + .menu-bar .submenu-badge { 269 + display: inline-block; 270 + background: #667eea; 271 + color: white; 272 + font-size: 10px; 273 + font-weight: 600; 274 + padding: 2px 5px; 275 + border-radius: 8px; 276 + min-width: 18px; 277 + text-align: center; 278 + line-height: 1.2; 279 + margin-left: auto; 280 + } 281 + 282 + .facet-pill { 283 + display: inline-flex; 284 + align-items: center; 285 + padding: 8px 16px; 286 + margin-right: 8px; 287 + border-radius: 20px; 288 + background: #f5f5f5; 289 + border: 1px solid #ddd; 290 + cursor: pointer; 291 + font-size: 14px; 292 + transition: all 0.2s ease; 293 + user-select: none; 294 + position: relative; 295 + } 296 + 297 + .facet-pill.icon-only { 298 + padding: 8px; 299 + } 300 + 301 + .facet-pill.icon-only > span:not(.emoji-container) { 302 + display: none; 303 + } 304 + 305 + .facet-pill.icon-only .emoji-container { 306 + margin-right: 0; 307 + } 308 + 309 + .facet-pill:hover { 310 + border-color: #999; 311 + transform: translateY(-1px); 312 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 313 + } 314 + 315 + .facet-pill.selected { 316 + border-color: #007bff; 317 + font-weight: 500; 318 + box-shadow: 0 2px 6px rgba(0,123,255,0.2); 319 + } 320 + 321 + .facet-pill .emoji-container { 322 + position: relative; 323 + font-size: 24px; 324 + line-height: 1; 325 + margin-right: 8px; 326 + display: flex; 327 + align-items: center; 328 + } 329 + 330 + .facet-pill .emoji { 331 + display: block; 332 + } 333 + 334 + .facet-pill .facet-badge { 335 + position: absolute; 336 + bottom: 0; 337 + right: -2px; 338 + background: #667eea; 339 + color: white; 340 + font-size: 9px; 341 + font-weight: 600; 342 + padding: 1px 4px; 343 + border-radius: 6px; 344 + min-width: 14px; 345 + text-align: center; 346 + line-height: 1.3; 347 + box-shadow: 0 1px 2px rgba(0,0,0,0.2); 348 + } 349 + 350 + .facet-pill.selected .facet-badge { 351 + background: #007bff; 352 + } 353 + 354 + /* App Bar (bottom) */ 355 + .app-bar { 356 + position: fixed; 357 + bottom: 0; 358 + left: 0; 359 + right: 0; 360 + background: white; 361 + border-top: 1px solid var(--facet-border, #e0e0e0); 362 + z-index: 1000; 363 + height: 60px; 364 + display: flex; 365 + align-items: center; 366 + padding: 0 16px; 367 + gap: 12px; 368 + transition: border-top-color 0.3s ease; 369 + } 370 + 371 + .app-bar::before { 372 + content: ''; 373 + position: absolute; 374 + top: 0; 375 + left: 0; 376 + right: 0; 377 + bottom: 0; 378 + background: var(--facet-bg, transparent); 379 + transition: background-color 0.3s ease; 380 + pointer-events: none; 381 + z-index: -1; 382 + } 383 + 384 + /* Workspace content area */ 385 + .workspace { 386 + flex: 1; 387 + display: flex; 388 + align-items: center; 389 + justify-content: center; 390 + gap: 12px; 391 + overflow: hidden; 392 + } 393 + </style> 394 + </head> 395 + <body{% if app_registry.apps[app].get_app_bar_template() %} class="has-app-bar"{% endif %}> 396 + <!-- Menu Bar (left sidebar) --> 397 + <div class="menu-bar"> 398 + {% for app_name, app_data in apps.items() %} 399 + <a href="/app/{{ app_name }}" class="menu-item{% if app == app_name %} current{% endif %}"> 400 + <span class="icon">{{ app_data['icon'] }}</span> 401 + <span class="label">{{ app_data['label'] }}</span> 402 + </a> 403 + {% if app_data.get('submenu') %} 404 + <div class="submenu"> 405 + {% for item in app_data['submenu'] %} 406 + <a href="{{ item['path'] }}" class="submenu-item" {% if item.get('facet') %}data-facet="{{ item['facet'] }}"{% endif %}> 407 + <span>{{ item['label'] }}</span> 408 + {% if item.get('count') and item['count'] > 0 %} 409 + <span class="submenu-badge">{{ item['count'] }}</span> 410 + {% endif %} 411 + </a> 412 + {% endfor %} 413 + </div> 414 + {% endif %} 415 + {% endfor %} 416 + </div> 417 + 418 + <!-- Facet Bar (top) --> 419 + <div class="facet-bar"> 420 + <div id="hamburger">☰</div> 421 + <div class="app-icon">{{ apps[app]['icon'] }}</div> 422 + <div class="facet-pills-container"></div> 423 + <div class="status-icon">🟢</div> 424 + </div> 425 + 426 + <!-- Status Pane --> 427 + <div class="status-pane"> 428 + <div class="status-pane-content"> 429 + <h3>System Status</h3> 430 + <p>All services operational</p> 431 + </div> 432 + </div> 433 + 434 + <!-- App Bar (bottom) - only render if app provides template --> 435 + {% set app_bar_template = app_registry.apps[app].get_app_bar_template() %} 436 + {% if app_bar_template %} 437 + <div class="app-bar"> 438 + <div class="workspace"> 439 + {% include app_bar_template %} 440 + </div> 441 + </div> 442 + {% endif %} 443 + 444 + <!-- Main Content --> 445 + <div class="container"> 446 + {% include app_registry.apps[app].get_workspace_template() %} 447 + </div> 448 + 449 + <script> 450 + // Facet filtering state 451 + let activeFacets = []; 452 + let selectedFacet = null; // null means "All" 453 + 454 + // Save facet selection to cookie (server-driven) 455 + function saveSelectedFacetToCookie(facet) { 456 + if (facet) { 457 + const expires = new Date(); 458 + expires.setFullYear(expires.getFullYear() + 1); 459 + document.cookie = `selectedFacet=${facet}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; 460 + } else { 461 + document.cookie = 'selectedFacet=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax'; 462 + } 463 + } 464 + 465 + // Convert hex color to rgba with opacity 466 + function hexToRgba(hex, alpha) { 467 + if (!hex || hex.length < 6) return `rgba(128,128,128,${alpha})`; 468 + const r = parseInt(hex.substring(1,3), 16); 469 + const g = parseInt(hex.substring(3,5), 16); 470 + const b = parseInt(hex.substring(5,7), 16); 471 + return `rgba(${r},${g},${b},${alpha})`; 472 + } 473 + 474 + // Load facets from embedded data 475 + function loadFacetChooser() { 476 + activeFacets = window.facetsData || []; 477 + 478 + // Enrich facets with app-specific counts 479 + const appCounts = {{ app_registry.apps[app].get_facet_counts(facets, selected_facet)|tojson|safe }}; 480 + activeFacets.forEach(facet => { 481 + facet.count = appCounts[facet.name] || 0; 482 + }); 483 + 484 + renderFacetChooser(); 485 + } 486 + 487 + // Render facet pills in top bar 488 + function renderFacetChooser() { 489 + const facetPillsContainer = document.querySelector('.facet-pills-container'); 490 + facetPillsContainer.innerHTML = ''; 491 + 492 + // Find selected facet data 493 + const selectedFacetData = selectedFacet ? activeFacets.find(f => f.name === selectedFacet) : null; 494 + 495 + // Apply theme by updating CSS variables 496 + if (selectedFacetData && selectedFacetData.color) { 497 + const color = selectedFacetData.color; 498 + const bgColor = color + '1a'; // 10% opacity 499 + 500 + document.documentElement.style.setProperty('--facet-color', color); 501 + document.documentElement.style.setProperty('--facet-bg', bgColor); 502 + document.documentElement.style.setProperty('--facet-border', color); 503 + } else { 504 + // Clear facet variables to use defaults 505 + document.documentElement.style.removeProperty('--facet-color'); 506 + document.documentElement.style.removeProperty('--facet-bg'); 507 + document.documentElement.style.removeProperty('--facet-border'); 508 + } 509 + 510 + // Facet pills 511 + activeFacets.forEach(facet => { 512 + const pill = document.createElement('div'); 513 + pill.className = 'facet-pill' + (selectedFacet === facet.name ? ' selected' : ''); 514 + 515 + if (facet.emoji) { 516 + const emojiContainer = document.createElement('div'); 517 + emojiContainer.className = 'emoji-container'; 518 + 519 + const emoji = document.createElement('span'); 520 + emoji.className = 'emoji'; 521 + emoji.textContent = facet.emoji; 522 + emojiContainer.appendChild(emoji); 523 + 524 + // Add badge if count > 0 525 + const count = facet.count || 0; 526 + if (count > 0) { 527 + const badge = document.createElement('span'); 528 + badge.className = 'facet-badge'; 529 + badge.textContent = count; 530 + emojiContainer.appendChild(badge); 531 + } 532 + 533 + pill.appendChild(emojiContainer); 534 + } 535 + 536 + const title = document.createElement('span'); 537 + title.textContent = facet.title; 538 + pill.appendChild(title); 539 + 540 + // Apply color with opacity if facet is selected and has a color 541 + if (selectedFacet === facet.name && facet.color) { 542 + pill.style.background = hexToRgba(facet.color, 0.2); 543 + pill.style.borderColor = facet.color; 544 + } 545 + 546 + pill.onclick = () => selectFacet(facet.name); 547 + facetPillsContainer.appendChild(pill); 548 + }); 549 + } 550 + 551 + // Update selection styles without re-rendering 552 + function updateFacetSelection() { 553 + const container = document.querySelector('.facet-pills-container'); 554 + const pills = container.querySelectorAll('.facet-pill'); 555 + 556 + // Find selected facet data 557 + const selectedFacetData = selectedFacet ? activeFacets.find(f => f.name === selectedFacet) : null; 558 + 559 + // Apply theme by updating CSS variables 560 + if (selectedFacetData && selectedFacetData.color) { 561 + const color = selectedFacetData.color; 562 + const bgColor = color + '1a'; // 10% opacity 563 + 564 + document.documentElement.style.setProperty('--facet-color', color); 565 + document.documentElement.style.setProperty('--facet-bg', bgColor); 566 + document.documentElement.style.setProperty('--facet-border', color); 567 + } else { 568 + // Clear facet variables to use defaults 569 + document.documentElement.style.removeProperty('--facet-color'); 570 + document.documentElement.style.removeProperty('--facet-bg'); 571 + document.documentElement.style.removeProperty('--facet-border'); 572 + } 573 + 574 + // Update pill selection states 575 + pills.forEach((pill, index) => { 576 + const facetName = activeFacets[index]?.name; 577 + 578 + // Update selected class 579 + if (selectedFacet === facetName) { 580 + pill.classList.add('selected'); 581 + 582 + // Apply color styling if selected and has color 583 + if (selectedFacetData && selectedFacetData.color) { 584 + pill.style.background = hexToRgba(selectedFacetData.color, 0.2); 585 + pill.style.borderColor = selectedFacetData.color; 586 + } else { 587 + pill.style.background = ''; 588 + pill.style.borderColor = ''; 589 + } 590 + } else { 591 + pill.classList.remove('selected'); 592 + pill.style.background = ''; 593 + pill.style.borderColor = ''; 594 + } 595 + }); 596 + } 597 + 598 + // Handle facet selection 599 + function selectFacet(facet) { 600 + selectedFacet = facet; 601 + saveSelectedFacetToCookie(facet); 602 + updateFacetSelection(); 603 + } 604 + 605 + // Toggle sidebar 606 + function toggleSidebar() { 607 + document.body.classList.toggle('sidebar-open'); 608 + } 609 + 610 + // Initialize facet selection from server 611 + selectedFacet = window.selectedFacetFromServer; 612 + 613 + // Load facet chooser 614 + loadFacetChooser(); 615 + 616 + // Use ResizeObserver to collapse pills when container width changes 617 + const facetPillsContainer = document.querySelector('.facet-pills-container'); 618 + 619 + if (facetPillsContainer) { 620 + const resizeObserver = new ResizeObserver(() => { 621 + collapseFacetPills(); 622 + }); 623 + 624 + resizeObserver.observe(facetPillsContainer); 625 + } 626 + 627 + function collapseFacetPills() { 628 + const container = document.querySelector('.facet-pills-container'); 629 + const pills = Array.from(container.querySelectorAll('.facet-pill')); 630 + 631 + if (!container || pills.length === 0) return; 632 + 633 + // Reset all pills to full display 634 + pills.forEach(pill => pill.classList.remove('icon-only')); 635 + 636 + // Force a reflow to get accurate measurements 637 + container.offsetWidth; 638 + 639 + // Check if we're overflowing 640 + const containerWidth = container.clientWidth; 641 + let totalWidth = 0; 642 + 643 + pills.forEach(pill => { 644 + totalWidth += pill.offsetWidth + 8; // Include margin 645 + }); 646 + 647 + // If overflowing, collapse pills from right to left 648 + if (totalWidth > containerWidth) { 649 + // Start from the end (right side) and collapse until we fit 650 + for (let i = pills.length - 1; i >= 0; i--) { 651 + const pill = pills[i]; 652 + 653 + pill.classList.add('icon-only'); 654 + 655 + // Force reflow and recalculate 656 + container.offsetWidth; 657 + 658 + totalWidth = 0; 659 + pills.forEach(p => { 660 + totalWidth += p.offsetWidth + 8; 661 + }); 662 + 663 + // If we fit now, stop collapsing 664 + if (totalWidth <= containerWidth) break; 665 + } 666 + } 667 + } 668 + 669 + // Initial collapse check after DOM settles 670 + setTimeout(collapseFacetPills, 0); 671 + 672 + // Hamburger menu interactions 673 + const hamburger = document.getElementById('hamburger'); 674 + 675 + hamburger.addEventListener('click', (e) => { 676 + e.stopPropagation(); 677 + toggleSidebar(); 678 + }); 679 + 680 + // App icon click - clear facet selection 681 + const appIcon = document.querySelector('.facet-bar .app-icon'); 682 + 683 + if (appIcon) { 684 + appIcon.addEventListener('click', (e) => { 685 + selectedFacet = null; 686 + saveSelectedFacetToCookie(null); 687 + updateFacetSelection(); 688 + }); 689 + } 690 + 691 + // Handle submenu items with data-facet attribute 692 + document.querySelectorAll('.submenu-item[data-facet]').forEach(item => { 693 + item.addEventListener('click', (e) => { 694 + e.preventDefault(); 695 + const facetName = item.getAttribute('data-facet'); 696 + const targetPath = item.getAttribute('href'); 697 + 698 + // Select the facet (sets cookie and updates UI) 699 + selectFacet(facetName); 700 + 701 + // Navigate to the path 702 + window.location.href = targetPath; 703 + }); 704 + }); 705 + 706 + // Status icon and pane 707 + const statusIcon = document.querySelector('.status-icon'); 708 + const statusPane = document.querySelector('.status-pane'); 709 + let statusPaneOpen = false; 710 + 711 + if (statusIcon && statusPane) { 712 + statusIcon.addEventListener('click', (e) => { 713 + e.stopPropagation(); 714 + statusPaneOpen = !statusPaneOpen; 715 + 716 + if (statusPaneOpen) { 717 + statusPane.classList.add('visible'); 718 + } else { 719 + statusPane.classList.remove('visible'); 720 + } 721 + }); 722 + } 723 + 724 + // Close sidebar and status pane when clicking outside 725 + document.addEventListener('click', (e) => { 726 + // Close sidebar 727 + if (document.body.classList.contains('sidebar-open')) { 728 + const menuBar = document.querySelector('.menu-bar'); 729 + const facetBar = document.querySelector('.facet-bar'); 730 + if (!menuBar.contains(e.target) && !facetBar.contains(e.target)) { 731 + document.body.classList.remove('sidebar-open'); 732 + } 733 + } 734 + 735 + // Close status pane 736 + if (statusPaneOpen && statusPane && statusIcon && 737 + !statusIcon.contains(e.target) && !statusPane.contains(e.target)) { 738 + statusPaneOpen = false; 739 + statusPane.classList.remove('visible'); 740 + } 741 + }); 742 + </script> 743 + </body> 744 + </html>
+2 -1
pyproject.toml
··· 120 120 "Bug Tracker" = "https://github.com/yourusername/sunstone/issues" 121 121 122 122 [tool.setuptools.packages.find] 123 - include = ["think*", "convey*", "observe*", "muse*"] 123 + include = ["apps*", "think*", "convey*", "observe*", "muse*"] 124 124 125 125 [tool.setuptools.package-data] 126 + apps = ["*/templates/*.html"] 126 127 think = ["*.txt", "topics/*.txt"] 127 128 muse = ["agents/*.txt", "agents/*.json"] 128 129 observe = ["*.txt", "*.json"]