diff --git a/solstone/apps/settings/routes.py b/solstone/apps/settings/routes.py index c8a71a50d..ebbc31e83 100644 --- a/solstone/apps/settings/routes.py +++ b/solstone/apps/settings/routes.py @@ -26,6 +26,7 @@ from solstone.apps.settings import install_copy, transcribe_resource from solstone.apps.utils import log_app_action from solstone.convey import chat_stream, state from solstone.convey import copy as convey_copy +from solstone.convey.icons import resolve_facet_icon_svg from solstone.convey.reasons import ( ACTIVITY_INVALID, ACTIVITY_NOT_FOUND, @@ -121,6 +122,10 @@ def _public_facet_record(name: str, data: dict[str, object]) -> dict[str, object "title": str(data.get("title") or name), "color": str(data.get("color") or ""), "emoji": str(data.get("emoji") or ""), + "icon": str(data.get("icon") or ""), + "icon_svg": resolve_facet_icon_svg( + data.get("icon"), str(data.get("emoji") or "") + ), "muted": bool(data.get("muted", False)), } @@ -1161,6 +1166,20 @@ def get_muted_facets() -> Any: return _settings_operation_failed() +@settings_bp.route("/api/icons") +def search_icons() -> Any: + try: + from solstone.convey.icons import search_lucide_icons + + q = request.args.get("q", "") + limit = request.args.get("limit", default=80, type=int) or 80 + limit = max(1, min(limit, 200)) + return jsonify({"icons": search_lucide_icons(q, limit=limit)}) + except Exception: + logger.exception("error searching icons") + return _settings_operation_failed() + + @settings_bp.route("/api/facet", methods=["POST"]) def create_facet() -> Any: """Create a new facet. @@ -1184,6 +1203,7 @@ def create_facet() -> Any: # Optional fields with defaults emoji = data.get("emoji", "📦") color = data.get("color", "#667eea") + icon = (data.get("icon") or "").strip() # Generate slug from title: lowercase, replace spaces/special chars with hyphens slug = re.sub(r"[^a-z0-9]+", "-", title.lower()) @@ -1203,7 +1223,7 @@ def create_facet() -> Any: detail=f"Facet '{slug}' already exists", ) - facets.create_facet(title, emoji=emoji, color=color) + facets.create_facet(title, emoji=emoji, color=color, icon=icon) config = { "title": title, @@ -1211,6 +1231,8 @@ def create_facet() -> Any: "color": color, "emoji": emoji, } + if icon: + config["icon"] = icon return jsonify({"success": True, "facet": slug, "config": config}), 201 @@ -1250,7 +1272,7 @@ def update_facet_config(facet_name: str) -> Any: update_fields = { key: data[key] - for key in ("title", "description", "color", "emoji") + for key in ("title", "description", "color", "emoji", "icon") if key in data } if update_fields: @@ -1266,6 +1288,8 @@ def update_facet_config(facet_name: str) -> Any: return jsonify({"success": True, "facet": facet_name, "config": config}) except FileNotFoundError: return error_response(FACET_NOT_FOUND, detail="Facet not found") + except ValueError as e: + return error_response(INVALID_REQUEST_VALUE, detail=str(e)) except Exception: logger.exception("error saving facet config") return _settings_operation_failed() diff --git a/solstone/apps/settings/tests/test_facet_config_routes.py b/solstone/apps/settings/tests/test_facet_config_routes.py index 76e9cb180..b3790f3e4 100644 --- a/solstone/apps/settings/tests/test_facet_config_routes.py +++ b/solstone/apps/settings/tests/test_facet_config_routes.py @@ -7,6 +7,7 @@ import json from solstone.apps.settings import routes as settings_routes from solstone.convey import create_app +from solstone.convey.icons import _lucide_tags from solstone.think import facets @@ -88,6 +89,26 @@ def test_create_facet_route_maps_owner_value_error(settings_env, monkeypatch): assert _reason_code(response) == "invalid_request_value" +def test_create_facet_route_with_icon_persists_and_returns_config(settings_env): + journal, client = _settings_client(settings_env) + payload = { + "title": "Treasury", + "emoji": "\U0001fa99", + "color": "#007bff", + "icon": "coins", + } + + response = client.post("/app/settings/api/facet", json=payload) + + assert response.status_code == 201 + body = response.get_json() + assert body["config"]["icon"] == "coins" + facet_payload = json.loads( + (journal / "facets" / "treasury" / "facet.json").read_text(encoding="utf-8") + ) + assert facet_payload["icon"] == "coins" + + def test_update_facet_route_updates_field_and_returns_config_without_path(settings_env): journal, client = _settings_client(settings_env) slug = facets.create_facet("Research", emoji="R", color="#667eea") @@ -115,6 +136,79 @@ def test_update_facet_route_updates_field_and_returns_config_without_path(settin ) == _facet_json_bytes(expected_file) +def test_update_facet_route_icon_round_trip(settings_env): + _journal, client = _settings_client(settings_env) + slug = facets.create_facet("Research", emoji="📚", color="#667eea") + + response = client.put( + f"/app/settings/api/facet/{slug}", + json={"icon": "brain"}, + ) + reload_response = client.get(f"/app/settings/api/facet/{slug}") + + assert response.status_code == 200 + assert response.get_json()["config"]["icon"] == "brain" + assert reload_response.status_code == 200 + assert reload_response.get_json()["config"]["icon"] == "brain" + + +def test_update_facet_route_invalid_icon_maps_value_error(settings_env): + journal, client = _settings_client(settings_env) + slug = facets.create_facet("Stable", emoji="S", color="#667eea") + facet_path = journal / "facets" / slug / "facet.json" + before = facet_path.read_text(encoding="utf-8") + + response = client.put( + f"/app/settings/api/facet/{slug}", + json={"title": "Changed", "icon": "bogus"}, + ) + + assert response.status_code == 400 + assert _reason_code(response) == "invalid_request_value" + assert "open the picker or see lucide.dev/icons" in response.get_json()["detail"] + assert facet_path.read_text(encoding="utf-8") == before + + +def test_update_facet_route_icon_clear_removes_key(settings_env): + journal, client = _settings_client(settings_env) + slug = facets.create_facet("Clearable", emoji="C", color="#667eea", icon="brain") + + response = client.put( + f"/app/settings/api/facet/{slug}", + json={"icon": ""}, + ) + + assert response.status_code == 200 + body = response.get_json() + assert "icon" not in body["config"] + facet_payload = json.loads( + (journal / "facets" / slug / "facet.json").read_text(encoding="utf-8") + ) + assert "icon" not in facet_payload + + +def test_icons_route_search_and_limit(settings_env): + _journal, client = _settings_client(settings_env) + tags = _lucide_tags() + + lock = client.get("/app/settings/api/icons?q=lock") + default = client.get("/app/settings/api/icons") + limited = client.get("/app/settings/api/icons?limit=5") + + assert lock.status_code == 200 + lock_icons = lock.get_json()["icons"] + assert lock_icons + assert all( + "lock" in icon["name"] + or any("lock" in tag for tag in tags.get(icon["name"], [])) + for icon in lock_icons + ) + assert default.status_code == 200 + assert len(default.get_json()["icons"]) <= 80 + assert limited.status_code == 200 + assert len(limited.get_json()["icons"]) <= 5 + + def test_update_facet_route_muted_only_regression(settings_env, monkeypatch): journal, client = _settings_client(settings_env) slug = facets.create_facet("Quiet", emoji="Q", color="#667eea") diff --git a/solstone/apps/settings/tests/test_facet_detail.py b/solstone/apps/settings/tests/test_facet_detail.py index 3b5d57410..069c8dfe9 100644 --- a/solstone/apps/settings/tests/test_facet_detail.py +++ b/solstone/apps/settings/tests/test_facet_detail.py @@ -9,6 +9,7 @@ from pathlib import Path from solstone.apps.settings import copy as settings_copy from solstone.convey import create_app +from solstone.convey.icons import lucide_svg def _write_facet( @@ -17,6 +18,7 @@ def _write_facet( *, title: str, emoji: str = "TF", + icon: str = "", color: str = "#123456", muted: bool = False, ) -> None: @@ -28,6 +30,8 @@ def _write_facet( "emoji": emoji, "color": color, } + if icon: + payload["icon"] = icon if muted: payload["muted"] = True (facet_dir / "facet.json").write_text( @@ -103,6 +107,20 @@ def test_settings_facets_api_returns_all_facets(settings_env): "title": "Active Facet", "color": "#123456", "emoji": "TF", + "icon": "", + "icon_svg": None, "muted": False, } assert by_name["muted-facet"]["muted"] is True + + +def test_settings_facets_api_returns_icon_override_svg(settings_env): + journal, client = _settings_client(settings_env) + _write_facet(journal, "icon-facet", title="Icon Facet", emoji="📚", icon="brain") + + response = client.get("/app/settings/api/facets") + + assert response.status_code == 200 + facet = response.get_json()["facets"][0] + assert facet["icon"] == "brain" + assert facet["icon_svg"] == lucide_svg("brain") diff --git a/solstone/apps/settings/workspace.html b/solstone/apps/settings/workspace.html index 26135009d..044646c68 100644 --- a/solstone/apps/settings/workspace.html +++ b/solstone/apps/settings/workspace.html @@ -630,6 +630,138 @@ input:checked + .slider:before { text-align: center; } +/* Lucide icon picker */ +.icon-picker-trigger { + display: inline-flex; + align-items: center; + gap: 0.75em; + cursor: pointer; + padding: 0.6em 0.75em; + border: 2px solid #ddd; + border-radius: 8px; + background: #fff; + color: var(--facet-color, #E8923A); + transition: all 0.2s; +} + +.icon-picker-trigger:hover { + background: #f5f5f5; + border-color: #999; +} + +.icon-picker-trigger:focus-visible { + outline: 2px solid var(--facet-color, #E8923A); + outline-offset: 2px; +} + +.icon-picker-preview, +.icon-modal-preview-glyph { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--pill-color, var(--facet-color, #E8923A)); +} + +.icon-picker-preview svg, +.icon-modal-preview-glyph svg, +.icon-result-btn svg { + width: 24px; + height: 24px; + stroke-width: 1.5; + color: currentColor; +} + +.icon-picker-name { + color: #333; + font-weight: 600; +} + +.icon-search-input { + width: 100%; + padding: 0.65em 0.75em; + border: 2px solid #ddd; + border-radius: 8px; + font: inherit; + margin-bottom: 1em; +} + +.icon-results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); + gap: 0.6em; + max-height: 280px; + overflow: auto; + margin-bottom: 1em; +} + +.icon-result-btn { + aspect-ratio: 1; + border: 2px solid #ddd; + border-radius: 8px; + background: #fff; + color: var(--pill-color, var(--facet-color, #E8923A)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.icon-result-btn:hover { + background: #f5f5f5; + border-color: #999; +} + +.icon-result-btn:active { + transform: scale(0.95); +} + +.icon-result-btn.selected { + border-color: var(--facet-color, #E8923A); + box-shadow: 0 0 0 2px #fff, 0 0 0 5px var(--facet-color, #E8923A); +} + +.icon-picker-message { + padding: 0.75em; + border-radius: 8px; + background: #f8f8f8; + color: #555; + margin-bottom: 1em; +} + +.icon-picker-message.error { + background: #fff4f4; + color: #a33; +} + +.icon-picker-actions { + display: flex; + gap: 0.75em; +} + +.icon-picker-actions .save-color-btn { + flex: 1; +} + +.icon-clear-btn { + flex: 1; + padding: 0.75em 1.5em; + background: #fff; + color: #333; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 1em; + font-weight: 600; + cursor: pointer; +} + +.icon-clear-btn:hover { + background: #f5f5f5; + border-color: #999; +} + /* Color picker */ .color-picker-trigger { display: flex; @@ -1862,6 +1994,9 @@ input:checked + .slider:before { .log-more-btn:focus-visible, .color-close:focus-visible, .color-swatch:focus-visible, +.icon-picker-trigger:focus-visible, +.icon-result-btn:focus-visible, +.icon-clear-btn:focus-visible, .field-display:focus-visible, button:focus-visible { outline: 2px solid var(--facet-color, #E8923A); @@ -1887,6 +2022,7 @@ button:focus-visible { .save-color-btn:focus:not(:focus-visible), .field-display:focus:not(:focus-visible), .color-picker-trigger:focus:not(:focus-visible), +.icon-picker-trigger:focus:not(:focus-visible), .emoji-display:focus:not(:focus-visible), button:focus:not(:focus-visible) { outline: none; @@ -2947,6 +3083,15 @@ button:focus:not(:focus-visible) { click to change emoji +
+ + + choose a Lucide override, or keep using the emoji +
+
@@ -3033,6 +3178,29 @@ button:focus:not(:focus-visible) {
+ + +