personal memory agent
0

Configure Feed

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

refactor(activities): store glyphs in emoji and Lucide names in icon

Activity types now mirror the facet model: emoji remains the glyph fallback and icon is the Lucide name. Routes resolve server-side icon_svg for activities/settings surfaces, while clients render icon_svg || escaped emoji for owner-supplied data.

The read path normalizes a legacy custom record with a glyph sitting in icon in memory, so the maint task is cleanup and never a prerequisite. The write boundary still rejects an emoji passed as icon; only solstone.think.activities writes facets/*/activities/activities.jsonl during migration.

+770 -126
+34
solstone/apps/activities/maint/000_migrate_activity_icon_to_emoji.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Migrate legacy custom activity glyphs from icon to emoji.""" 5 + 6 + from __future__ import annotations 7 + 8 + import argparse 9 + 10 + from solstone.think.activities import migrate_custom_activity_icons_to_emoji 11 + from solstone.think.utils import setup_cli 12 + 13 + 14 + def main() -> None: 15 + parser = argparse.ArgumentParser(description=__doc__) 16 + setup_cli(parser) 17 + parser.add_argument( 18 + "--dry-run", 19 + action="store_true", 20 + help="report records that would change without writing activity config", 21 + ) 22 + args = parser.parse_args() 23 + 24 + result = migrate_custom_activity_icons_to_emoji(dry_run=args.dry_run) 25 + action = "Would migrate" if args.dry_run else "Migrated" 26 + print( 27 + f"{action} {result['records_changed']} custom activity record(s) " 28 + f"across {result['files_changed']} file(s); " 29 + f"scanned {result['files_scanned']} file(s)." 30 + ) 31 + 32 + 33 + if __name__ == "__main__": 34 + main()
+11 -3
solstone/apps/activities/routes.py
··· 12 12 13 13 from solstone.convey import state 14 14 from solstone.convey.date_nav import build_date_nav_index 15 + from solstone.convey.icons import lucide_svg, resolve_icon_svg 15 16 from solstone.convey.reasons import ( 16 17 ACTIVITIES_BUSY, 17 18 ACTIVITY_ALREADY_EXISTS, ··· 59 60 url_prefix="/app/activities", 60 61 ) 61 62 62 - _GENERIC_ACTIVITY_ICON = "\U0001f5d3" 63 + _GENERIC_ACTIVITY_EMOJI = "\U0001f5d3" 64 + _GENERIC_ACTIVITY_LUCIDE = "calendar-days" 63 65 64 66 65 67 @activities_bp.route("/") ··· 373 375 activity_def = get_default_activity_by_id(activity_type) 374 376 375 377 name = activity_def.get("name", activity_type) if activity_def else activity_type 378 + emoji = activity_def.get("emoji", "") if activity_def else "" 376 379 icon = activity_def.get("icon", "") if activity_def else "" 377 - if not icon: 378 - icon = _GENERIC_ACTIVITY_ICON 380 + icon_svg = resolve_icon_svg(icon, "") if icon else None 381 + if not emoji and not icon_svg: 382 + emoji = _GENERIC_ACTIVITY_EMOJI 383 + icon = _GENERIC_ACTIVITY_LUCIDE 384 + icon_svg = lucide_svg(_GENERIC_ACTIVITY_LUCIDE) 379 385 380 386 segments = record.get("segments", []) 381 387 start_time = end_time = None ··· 428 434 "id": record["id"], 429 435 "activity": activity_type, 430 436 "name": name, 437 + "emoji": emoji, 431 438 "icon": icon, 439 + "icon_svg": icon_svg, 432 440 "facet": facet, 433 441 "description": record.get("description", ""), 434 442 "level_avg": record.get("level_avg", 0.5),
+61 -6
solstone/apps/activities/workspace.html
··· 45 45 border-radius: 6px; 46 46 display: flex; 47 47 align-items: flex-start; 48 + gap: 0.3em; 48 49 padding: 2px 6px; 49 50 font-size: 0.75em; 50 51 overflow: hidden; 51 52 white-space: nowrap; 52 53 transition: filter 0.15s ease; 53 54 box-shadow: none; 55 + } 56 + 57 + .occ-activity-icon { 58 + display: inline-flex; 59 + align-items: center; 60 + flex-shrink: 0; 61 + line-height: 1; 62 + } 63 + 64 + .occ-activity-icon svg { 65 + width: 1em; 66 + height: 1em; 67 + stroke-width: 1.5; 68 + stroke: currentColor; 69 + fill: none; 70 + } 71 + 72 + .occ-activity-name { 73 + min-width: 0; 74 + overflow: hidden; 75 + text-overflow: ellipsis; 54 76 } 55 77 56 78 h1 { ··· 122 144 } 123 145 124 146 .activity-card .act-icon { 147 + display: inline-flex; 148 + align-items: center; 125 149 font-size: 1.3em; 126 150 flex-shrink: 0; 151 + line-height: 1; 152 + } 153 + 154 + .activity-card .act-icon svg { 155 + width: 1em; 156 + height: 1em; 157 + stroke-width: 1.5; 158 + stroke: currentColor; 159 + fill: none; 127 160 } 128 161 129 162 .activity-card .act-body { ··· 194 227 } 195 228 196 229 .activity-detail-header h2 { 230 + display: flex; 231 + align-items: center; 232 + gap: 0.4em; 197 233 margin: 0 0 0.25em 0; 198 234 font-size: 1.3em; 235 + } 236 + 237 + .activity-detail-icon { 238 + display: inline-flex; 239 + align-items: center; 240 + flex-shrink: 0; 241 + line-height: 1; 242 + } 243 + 244 + .activity-detail-icon svg { 245 + width: 1em; 246 + height: 1em; 247 + stroke-width: 1.5; 248 + stroke: currentColor; 249 + fill: none; 199 250 } 200 251 201 252 .activity-detail-header .ad-subtitle { ··· 716 767 const top = ((sMin - startHour * 60) / totalMinutes) * 100; 717 768 const height = Math.max((eMin - sMin) / totalMinutes * 100, 0.4); 718 769 const color = levelColor(a.level_avg); 719 - const icon = a.icon || '🗓️'; 720 - html += `<a class='occ occ-activity' data-activity-id='${escapeHtml(a.id)}' tabindex='0' role='button' style='top:${top}%;height:${height}%;left:0;width:100%;border-color:${color};background:${color}40' title='${escapeHtml(icon + " " + a.name)}'>${escapeHtml(icon)} ${escapeHtml(a.name)}</a>`; 770 + const iconHtml = activityIconHtml(a); 771 + html += `<a class='occ occ-activity' data-activity-id='${escapeHtml(a.id)}' tabindex='0' role='button' style='top:${top}%;height:${height}%;left:0;width:100%;border-color:${color};background:${color}40' title='${escapeHtml(a.name)}'><span class="occ-activity-icon" aria-hidden="true">${iconHtml}</span><span class="occ-activity-name">${escapeHtml(a.name)}</span></a>`; 721 772 }); 722 773 723 774 html += '</section>'; 724 775 div.innerHTML = html; 776 + } 777 + 778 + function activityIconHtml(activity) { 779 + return activity.icon_svg || escapeHtml(activity.emoji || ''); 725 780 } 726 781 727 782 // ── Activities cards ── ··· 741 796 const lvl = levelLabel(a.level_avg); 742 797 const timeStr = a.startTime ? fmtTime(a.startTime) : ''; 743 798 const color = levelColor(a.level_avg); 744 - const icon = a.icon || '🗓️'; 799 + const iconHtml = activityIconHtml(a); 745 800 html += `<div class="activity-card" data-activity-id="${escapeHtml(a.id)}" tabindex="0" role="button" style="border-left-color:${color}">`; 746 - html += `<span class="act-icon">${escapeHtml(icon)}</span>`; 801 + html += `<span class="act-icon" aria-hidden="true">${iconHtml}</span>`; 747 802 html += `<div class="act-body">`; 748 803 html += `<div class="act-name">${escapeHtml(a.name)}</div>`; 749 804 if (a.description) html += `<div class="act-desc">${escapeHtml(a.description)}</div>`; ··· 783 838 const timeRange = a.startTime 784 839 ? `${fmtTime(a.startTime)}${a.endTime ? ' – ' + fmtTime(a.endTime) : ''}` 785 840 : ''; 786 - const icon = a.icon || '🗓️'; 841 + const iconHtml = activityIconHtml(a); 787 842 788 843 let html = ''; 789 844 html += `<button type="button" class="activity-detail-back">← back to day</button>`; 790 845 html += `<div class="activity-detail-header">`; 791 - html += `<h2>${escapeHtml(icon)} ${escapeHtml(a.name)}</h2>`; 846 + html += `<h2><span class="activity-detail-icon" aria-hidden="true">${iconHtml}</span><span>${escapeHtml(a.name)}</span></h2>`; 792 847 html += `<div class="ad-subtitle">${escapeHtml(a.facet)}</div>`; 793 848 html += `</div>`; 794 849
+52 -12
solstone/apps/settings/routes.py
··· 26 26 from solstone.apps.utils import log_app_action 27 27 from solstone.convey import chat_stream, state 28 28 from solstone.convey import copy as convey_copy 29 - from solstone.convey.icons import resolve_icon_svg 29 + from solstone.convey.icons import is_lucide_icon, resolve_icon_svg 30 30 from solstone.convey.reasons import ( 31 31 ACTIVITY_INVALID, 32 32 ACTIVITY_NOT_FOUND, ··· 131 131 "color": str(data.get("color") or ""), 132 132 "emoji": str(data.get("emoji") or ""), 133 133 "icon": str(data.get("icon") or ""), 134 - "icon_svg": resolve_icon_svg( 135 - data.get("icon"), str(data.get("emoji") or "") 136 - ), 134 + "icon_svg": resolve_icon_svg(data.get("icon"), str(data.get("emoji") or "")), 137 135 "muted": bool(data.get("muted", False)), 138 136 } 137 + 138 + 139 + def _public_activity_record(activity: dict[str, Any]) -> dict[str, Any]: 140 + payload = dict(activity) 141 + icon = payload.get("icon") 142 + payload["icon_svg"] = resolve_icon_svg(icon if isinstance(icon, str) else None, "") 143 + return payload 144 + 145 + 146 + def _public_activity_records(activities: list[dict[str, Any]]) -> list[dict[str, Any]]: 147 + return [_public_activity_record(activity) for activity in activities] 148 + 149 + 150 + def _validate_activity_icon(value: object) -> str | None: 151 + if value is None: 152 + return None 153 + if value == "": 154 + return "" 155 + if not isinstance(value, str) or not is_lucide_icon(value): 156 + raise ValueError("icon must be a Lucide name; send emoji in emoji") 157 + return value 139 158 140 159 141 160 # API keys that can be configured in the env section ··· 1489 1508 try: 1490 1509 from solstone.think.activities import get_default_activities as _get_defaults 1491 1510 1492 - return jsonify({"activities": _get_defaults()}) 1511 + return jsonify({"activities": _public_activity_records(_get_defaults())}) 1493 1512 except Exception: 1494 1513 logger.exception("error loading default activities") 1495 1514 return _settings_operation_failed() ··· 1518 1537 attached = _get_facet_activities(facet_name) 1519 1538 defaults = _get_defaults() 1520 1539 1521 - return jsonify({"activities": attached, "defaults": defaults}) 1540 + return jsonify( 1541 + { 1542 + "activities": _public_activity_records(attached), 1543 + "defaults": _public_activity_records(defaults), 1544 + } 1545 + ) 1522 1546 1523 1547 except Exception: 1524 1548 logger.exception("error loading facet activities") ··· 1537 1561 name: Display name (required for custom, optional for predefined) 1538 1562 description: Activity description (optional) 1539 1563 priority: "high", "normal", or "low" (optional, default: "normal") 1540 - icon: Emoji icon (optional, for custom activities) 1564 + emoji: Glyph fallback (optional, for custom activities) 1565 + icon: Lucide icon name (optional, for custom activities) 1541 1566 """ 1542 1567 try: 1543 1568 from solstone.think.activities import ( ··· 1588 1613 detail="'name' is required for custom activities", 1589 1614 ) 1590 1615 1616 + try: 1617 + icon = _validate_activity_icon(data.get("icon")) 1618 + except ValueError as exc: 1619 + return error_response(ACTIVITY_INVALID, detail=str(exc)) 1620 + 1591 1621 activity = add_activity_to_facet( 1592 1622 facet_name, 1593 1623 activity_id, ··· 1595 1625 description=data.get("description"), 1596 1626 instructions=data.get("instructions"), 1597 1627 priority=priority, 1598 - icon=data.get("icon"), 1628 + emoji=data.get("emoji"), 1629 + icon=icon, 1599 1630 ) 1600 1631 1601 1632 log_app_action( ··· 1605 1636 params={"activity_id": activity_id}, 1606 1637 ) 1607 1638 1608 - return jsonify({"success": True, "activity": activity}), 201 1639 + return jsonify( 1640 + {"success": True, "activity": _public_activity_record(activity)} 1641 + ), 201 1609 1642 1610 1643 except Exception: 1611 1644 logger.exception("error adding activity") ··· 1621 1654 instructions: Detection/level instructions for the LLM 1622 1655 priority: "high", "normal", or "low" 1623 1656 name: New name (only for custom activities) 1624 - icon: New icon (only for custom activities) 1657 + emoji: New glyph fallback (only for custom activities) 1658 + icon: New Lucide icon name (only for custom activities) 1625 1659 """ 1626 1660 try: 1627 1661 from solstone.think.activities import update_activity_in_facet ··· 1644 1678 detail="priority must be 'high', 'normal', or 'low'", 1645 1679 ) 1646 1680 1681 + try: 1682 + icon = _validate_activity_icon(data.get("icon")) 1683 + except ValueError as exc: 1684 + return error_response(ACTIVITY_INVALID, detail=str(exc)) 1685 + 1647 1686 activity = update_activity_in_facet( 1648 1687 facet_name, 1649 1688 activity_id, ··· 1651 1690 instructions=data.get("instructions"), 1652 1691 priority=priority, 1653 1692 name=data.get("name"), 1654 - icon=data.get("icon"), 1693 + emoji=data.get("emoji"), 1694 + icon=icon, 1655 1695 ) 1656 1696 1657 1697 if activity is None: ··· 1667 1707 params={"activity_id": activity_id, "updates": data}, 1668 1708 ) 1669 1709 1670 - return jsonify({"success": True, "activity": activity}) 1710 + return jsonify({"success": True, "activity": _public_activity_record(activity)}) 1671 1711 1672 1712 except Exception: 1673 1713 logger.exception("error updating activity")
+64
solstone/apps/settings/tests/test_facet_detail.py
··· 9 9 from solstone.apps.settings import copy as settings_copy 10 10 from solstone.convey import create_app 11 11 from solstone.convey.icons import lucide_svg 12 + from solstone.convey.reasons import ACTIVITY_INVALID 12 13 13 14 14 15 def _write_facet( ··· 150 151 facet = response.get_json()["facets"][0] 151 152 assert facet["icon"] == "brain" 152 153 assert facet["icon_svg"] == lucide_svg("brain") 154 + 155 + 156 + def test_settings_activity_post_accepts_emoji_and_lucide_icon(settings_env): 157 + journal, client = _settings_client(settings_env) 158 + _write_facet(journal, "activity-facet", title="Activity Facet") 159 + 160 + response = client.post( 161 + "/app/settings/api/facet/activity-facet/activities", 162 + json={ 163 + "activity_id": "deep_work", 164 + "name": "Deep work", 165 + "description": "Focused custom work", 166 + "emoji": "🎯", 167 + "icon": "target", 168 + }, 169 + ) 170 + 171 + assert response.status_code == 201 172 + activity = response.get_json()["activity"] 173 + assert activity["emoji"] == "🎯" 174 + assert activity["icon"] == "target" 175 + assert activity["icon_svg"] == lucide_svg("target") 176 + 177 + 178 + def test_settings_activity_post_rejects_emoji_in_icon(settings_env): 179 + journal, client = _settings_client(settings_env) 180 + _write_facet(journal, "activity-facet", title="Activity Facet") 181 + 182 + response = client.post( 183 + "/app/settings/api/facet/activity-facet/activities", 184 + json={ 185 + "activity_id": "bad_icon", 186 + "name": "Bad icon", 187 + "emoji": "🎯", 188 + "icon": "🎯", 189 + }, 190 + ) 191 + 192 + assert response.status_code == 400 193 + assert response.get_json()["reason_code"] == ACTIVITY_INVALID.code 194 + 195 + 196 + def test_settings_activity_put_rejects_emoji_in_icon(settings_env): 197 + journal, client = _settings_client(settings_env) 198 + _write_facet(journal, "activity-facet", title="Activity Facet") 199 + created = client.post( 200 + "/app/settings/api/facet/activity-facet/activities", 201 + json={ 202 + "activity_id": "deep_work", 203 + "name": "Deep work", 204 + "emoji": "🎯", 205 + "icon": "target", 206 + }, 207 + ) 208 + assert created.status_code == 201 209 + 210 + response = client.put( 211 + "/app/settings/api/facet/activity-facet/activities/deep_work", 212 + json={"icon": "🎯"}, 213 + ) 214 + 215 + assert response.status_code == 400 216 + assert response.get_json()["reason_code"] == ACTIVITY_INVALID.code 153 217 154 218 155 219 def test_settings_index_has_hidden_guard():
+23 -2
solstone/apps/settings/workspace.html
··· 1601 1601 } 1602 1602 1603 1603 .activity-icon { 1604 + display: inline-flex; 1605 + align-items: center; 1604 1606 font-size: 1.5em; 1605 1607 line-height: 1; 1606 1608 flex-shrink: 0; 1609 + } 1610 + 1611 + .activity-icon svg { 1612 + width: 1em; 1613 + height: 1em; 1614 + stroke-width: 1.5; 1615 + stroke: currentColor; 1616 + fill: none; 1607 1617 } 1608 1618 1609 1619 .activity-info { ··· 1714 1724 } 1715 1725 1716 1726 .activity-chip-icon { 1727 + display: inline-flex; 1728 + align-items: center; 1717 1729 font-size: 1.1em; 1730 + line-height: 1; 1731 + } 1732 + 1733 + .activity-chip-icon svg { 1734 + width: 1em; 1735 + height: 1em; 1736 + stroke-width: 1.5; 1737 + stroke: currentColor; 1738 + fill: none; 1718 1739 } 1719 1740 1720 1741 .activities-custom { ··· 5829 5850 countEl.textContent = `(${attached.length})`; 5830 5851 attachedList.innerHTML = attached.map(a => ` 5831 5852 <div class="activity-item" data-id="${a.id}"> 5832 - <span class="activity-icon">${a.icon || '📌'}</span> 5853 + <span class="activity-icon" aria-hidden="true">${a.icon_svg || escapeHtml(a.emoji || '')}</span> 5833 5854 <div class="activity-info"> 5834 5855 <div class="activity-name">${escapeHtml(a.name)}</div> 5835 5856 <div class="activity-desc activity-desc-editable" data-id="${a.id}" title="double-click to edit">${escapeHtml(a.description) || '<em>no description</em>'}</div> ··· 5861 5882 const isAttached = attachedIds.has(d.id); 5862 5883 return ` 5863 5884 <span class="activity-chip ${isAttached ? 'activity-chip-attached' : ''}" data-id="${d.id}" ${isAttached ? '' : 'tabindex="0" role="button" title="click to add"'}> 5864 - <span class="activity-chip-icon">${d.icon || '📌'}</span> 5885 + <span class="activity-chip-icon" aria-hidden="true">${d.icon_svg || escapeHtml(d.emoji || '')}</span> 5865 5886 <span>${escapeHtml(d.name)}</span> 5866 5887 </span> 5867 5888 `;
+160 -33
solstone/think/activities.py
··· 27 27 28 28 logger = logging.getLogger(__name__) 29 29 ANTICIPATION_FUZZY_THRESHOLD = 0.85 30 + LUCIDE_ICON_NAME_RE = re.compile(r"^[a-z0-9-]+$") 30 31 31 32 # --------------------------------------------------------------------------- 32 33 # Default Activities ··· 40 41 "id": "meeting", 41 42 "name": "Meetings", 42 43 "description": "Video calls, in-person meetings, and conferences", 43 - "icon": "📅", 44 + "emoji": "📅", 45 + "icon": "users", 44 46 "always_on": True, 45 47 "instructions": ( 46 48 "Levels: high=actively speaking/presenting, medium=listening attentively," ··· 52 54 "id": "call", 53 55 "name": "call", 54 56 "description": "a call you have planned", 55 - "icon": "📞", 57 + "emoji": "📞", 58 + "icon": "phone", 56 59 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 57 60 }, 58 61 { 59 62 "id": "deadline", 60 63 "name": "deadline", 61 64 "description": "a deadline you are working toward", 62 - "icon": "⏰", 65 + "emoji": "⏰", 66 + "icon": "alarm-clock", 63 67 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 64 68 }, 65 69 { 66 70 "id": "appointment", 67 71 "name": "appointment", 68 72 "description": "an appointment on your calendar", 69 - "icon": "📌", 73 + "emoji": "📌", 74 + "icon": "pin", 70 75 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 71 76 }, 72 77 { 73 78 "id": "event", 74 79 "name": "event", 75 80 "description": "an event you plan to attend", 76 - "icon": "🎟️", 81 + "emoji": "🎟️", 82 + "icon": "ticket", 77 83 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 78 84 }, 79 85 { 80 86 "id": "travel", 81 87 "name": "travel", 82 88 "description": "travel you have planned", 83 - "icon": "✈️", 89 + "emoji": "✈️", 90 + "icon": "plane", 84 91 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 85 92 }, 86 93 { 87 94 "id": "reminder", 88 95 "name": "reminder", 89 96 "description": "a reminder for something upcoming", 90 - "icon": "🔔", 97 + "emoji": "🔔", 98 + "icon": "bell", 91 99 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 92 100 }, 93 101 { 94 102 "id": "errand", 95 103 "name": "errand", 96 104 "description": "an errand you plan to do", 97 - "icon": "🧾", 105 + "emoji": "🧾", 106 + "icon": "receipt", 98 107 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 99 108 }, 100 109 { 101 110 "id": "celebration", 102 111 "name": "celebration", 103 112 "description": "a celebration on the calendar", 104 - "icon": "🎉", 113 + "emoji": "🎉", 114 + "icon": "party-popper", 105 115 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 106 116 }, 107 117 { 108 118 "id": "doctor_appointment", 109 119 "name": "doctor appointment", 110 120 "description": "a medical appointment on your calendar", 111 - "icon": "🩺", 121 + "emoji": "🩺", 122 + "icon": "stethoscope", 112 123 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 113 124 }, 114 125 { 115 126 "id": "coding", 116 127 "name": "Coding", 117 128 "description": "Programming, code review, and debugging", 118 - "icon": "💻", 129 + "emoji": "💻", 130 + "icon": "code-xml", 119 131 "instructions": ( 120 132 "Levels: high=writing or debugging code, medium=reading/reviewing code," 121 133 " low=IDE or editor open but not focused." ··· 127 139 "id": "browsing", 128 140 "name": "Browsing", 129 141 "description": "Web browsing, research, and reading online", 130 - "icon": "🌐", 142 + "emoji": "🌐", 143 + "icon": "globe", 131 144 "instructions": ( 132 145 "Levels: high=actively navigating/researching, medium=reading a page," 133 146 " low=browser open but idle." ··· 138 151 "id": "email", 139 152 "name": "Email", 140 153 "description": "Email reading and composition", 141 - "icon": "📧", 154 + "emoji": "📧", 155 + "icon": "mail", 142 156 "always_on": True, 143 157 "instructions": ( 144 158 "Levels: high=composing or actively reading email," ··· 150 164 "id": "messaging", 151 165 "name": "Messaging", 152 166 "description": "Chat, Slack, Discord, and text messaging", 153 - "icon": "💬", 167 + "emoji": "💬", 168 + "icon": "messages-square", 154 169 "always_on": True, 155 170 "instructions": ( 156 171 "Levels: high=active conversation, medium=reading messages," ··· 162 177 "id": "ai_conversation", 163 178 "name": "AI Conversation", 164 179 "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini", 165 - "icon": "🤖", 180 + "emoji": "🤖", 181 + "icon": "bot", 166 182 "instructions": ( 167 183 "Levels: high=actively prompting and reading responses," 168 184 " medium=reviewing AI output or refining prompts," ··· 177 193 "id": "writing", 178 194 "name": "Writing", 179 195 "description": "Documents, notes, and long-form writing", 180 - "icon": "✍️", 196 + "emoji": "✍️", 197 + "icon": "pencil-line", 181 198 "instructions": ( 182 199 "Levels: high=actively composing text, medium=editing/revising," 183 200 " low=document open but not being edited." ··· 188 205 "id": "reading", 189 206 "name": "Reading", 190 207 "description": "Books, PDFs, articles, highlights, and documentation", 191 - "icon": "📖", 208 + "emoji": "📖", 209 + "icon": "book-open", 192 210 "instructions": ( 193 211 "Levels: high=focused reading, medium=skimming content," 194 212 " low=document open but attention elsewhere." ··· 201 219 "id": "video", 202 220 "name": "Video", 203 221 "description": "Watching videos and streaming content", 204 - "icon": "🎬", 222 + "emoji": "🎬", 223 + "icon": "clapperboard", 205 224 "instructions": ( 206 225 "Levels: high=actively watching, medium=video playing while" 207 226 " doing something else, low=video paused or minimized." ··· 212 231 "id": "gaming", 213 232 "name": "Gaming", 214 233 "description": "Games and entertainment", 215 - "icon": "🎮", 234 + "emoji": "🎮", 235 + "icon": "gamepad-2", 216 236 "instructions": ( 217 237 "Levels: high=actively playing, medium=in menus or waiting," 218 238 " low=game open but tabbed out." ··· 223 243 "id": "social", 224 244 "name": "Social Media", 225 245 "description": "Social media browsing and interaction", 226 - "icon": "📱", 246 + "emoji": "📱", 247 + "icon": "share-2", 227 248 "instructions": ( 228 249 "Levels: high=posting or actively engaging, medium=scrolling feed," 229 250 " low=social app open but idle." ··· 234 255 "id": "planning", 235 256 "name": "Planning", 236 257 "description": "Scheduling, calendar management, meeting preparation, and agenda setting", 237 - "icon": "📋", 258 + "emoji": "📋", 259 + "icon": "calendar-check", 238 260 "instructions": ( 239 261 "Levels: high=actively scheduling or preparing agendas," 240 262 " medium=reviewing calendar or event details," ··· 250 272 "id": "productivity", 251 273 "name": "Productivity", 252 274 "description": "Spreadsheets, slides, and task management", 253 - "icon": "📊", 275 + "emoji": "📊", 276 + "icon": "chart-column", 254 277 "instructions": ( 255 278 "Levels: high=actively editing or organizing, medium=reviewing data," 256 279 " low=app open but not focused." ··· 262 285 "id": "terminal", 263 286 "name": "Terminal", 264 287 "description": "Command line and shell sessions", 265 - "icon": "⌨️", 288 + "emoji": "⌨️", 289 + "icon": "terminal", 266 290 "instructions": ( 267 291 "Levels: high=running commands or scripts, medium=reading output," 268 292 " low=terminal open but idle." ··· 274 298 "id": "design", 275 299 "name": "Design", 276 300 "description": "Design tools and image editing", 277 - "icon": "🎨", 301 + "emoji": "🎨", 302 + "icon": "palette", 278 303 "instructions": ( 279 304 "Levels: high=actively creating or editing, medium=reviewing designs," 280 305 " low=design tool open but idle." ··· 285 310 "id": "music", 286 311 "name": "Music", 287 312 "description": "Music listening and audio", 288 - "icon": "🎵", 313 + "emoji": "🎵", 314 + "icon": "music", 289 315 "instructions": ( 290 316 "Levels: high=actively choosing or browsing music," 291 317 " medium=playlist running while working, low=ambient background audio." ··· 302 328 Returns a copy to prevent mutation. 303 329 """ 304 330 return [dict(a) for a in DEFAULT_ACTIVITIES] 331 + 332 + 333 + def _lucide_name_shaped(value: object) -> bool: 334 + return isinstance(value, str) and bool(LUCIDE_ICON_NAME_RE.fullmatch(value)) 335 + 336 + 337 + def _normalized_icon_fields(entry: dict[str, Any]) -> tuple[str | None, str | None]: 338 + """Return canonical (emoji, icon) fields for a stored activity config entry.""" 339 + emoji = entry.get("emoji") 340 + icon = entry.get("icon") 341 + 342 + if not isinstance(emoji, str) or not emoji: 343 + emoji = None 344 + 345 + if not isinstance(icon, str) or not icon: 346 + return emoji, None 347 + 348 + if _lucide_name_shaped(icon): 349 + return emoji, icon 350 + 351 + return emoji or icon, None 305 352 306 353 307 354 def _get_activities_path(facet: str) -> Path: ··· 358 405 - id: Activity identifier 359 406 - name: Display name 360 407 - description: Activity description 361 - - icon: Emoji icon (if predefined) 408 + - emoji: Glyph fallback 409 + - icon: Lucide icon name 362 410 - priority: "high", "normal", or "low" 363 411 - custom: True if user-created (not in defaults) 364 412 - always_on: True if auto-included from defaults ··· 395 443 else: 396 444 activity = {"id": activity_id, "custom": True} 397 445 446 + emoji, icon = _normalized_icon_fields(fa) 447 + 398 448 # Apply facet overrides 399 449 if "name" in fa: 400 450 activity["name"] = fa["name"] ··· 402 452 activity["description"] = fa["description"] 403 453 if "priority" in fa: 404 454 activity["priority"] = fa["priority"] 405 - if "icon" in fa: 406 - activity["icon"] = fa["icon"] 455 + if emoji is not None: 456 + activity["emoji"] = emoji 457 + if icon is not None: 458 + activity["icon"] = icon 407 459 if "instructions" in fa: 408 460 activity["instructions"] = fa["instructions"] 409 461 ··· 437 489 - description: Activity description 438 490 Optional for all: 439 491 - priority: "high", "normal", or "low" 440 - - icon: Emoji icon 492 + - emoji: Glyph fallback 493 + - icon: Lucide icon name 441 494 - instructions: Detection/level instructions for the LLM 442 495 """ 443 496 # Build lookup for defaults to determine what needs to be stored ··· 482 535 entry["instructions"] = activity["instructions"] 483 536 if activity.get("priority"): 484 537 entry["priority"] = activity["priority"] 538 + if activity.get("emoji"): 539 + entry["emoji"] = activity["emoji"] 485 540 if activity.get("icon"): 486 541 entry["icon"] = activity["icon"] 487 542 ··· 490 545 _save_activities_jsonl(facet, entries) 491 546 492 547 548 + def migrate_custom_activity_icons_to_emoji( 549 + *, dry_run: bool = False 550 + ) -> dict[str, int | bool]: 551 + """Migrate legacy custom activity glyphs from icon to emoji. 552 + 553 + Stored custom activities used to treat ``icon`` as an emoji glyph. The 554 + current shape reserves ``icon`` for Lucide names and stores glyphs in 555 + ``emoji``. Predefined activities are intentionally left alone because 556 + public write paths never stored icon overrides for them. 557 + """ 558 + defaults_by_id = {a["id"]: a for a in DEFAULT_ACTIVITIES} 559 + facets_dir = Path(get_journal()) / "facets" 560 + result: dict[str, int | bool] = { 561 + "dry_run": dry_run, 562 + "files_scanned": 0, 563 + "files_changed": 0, 564 + "records_changed": 0, 565 + } 566 + if not facets_dir.exists(): 567 + return result 568 + 569 + for path in sorted(facets_dir.glob("*/activities/activities.jsonl")): 570 + facet = path.parent.parent.name 571 + records = _load_activities_jsonl(facet) 572 + result["files_scanned"] = int(result["files_scanned"]) + 1 573 + changed = False 574 + 575 + for record in records: 576 + activity_id = record.get("id") 577 + if not activity_id: 578 + continue 579 + if not (record.get("custom") or activity_id not in defaults_by_id): 580 + continue 581 + 582 + record_changed = False 583 + emoji, icon = _normalized_icon_fields(record) 584 + if emoji is not None and record.get("emoji") != emoji: 585 + record["emoji"] = emoji 586 + record_changed = True 587 + if icon is None and "icon" in record: 588 + record.pop("icon", None) 589 + record_changed = True 590 + elif icon is not None and record.get("icon") != icon: 591 + record["icon"] = icon 592 + record_changed = True 593 + 594 + if record_changed: 595 + changed = True 596 + result["records_changed"] = int(result["records_changed"]) + 1 597 + 598 + if changed: 599 + result["files_changed"] = int(result["files_changed"]) + 1 600 + if not dry_run: 601 + _save_activities_jsonl(facet, records) 602 + 603 + return result 604 + 605 + 493 606 def get_default_activity_by_id(activity_id: str) -> dict[str, Any] | None: 494 607 """Look up a predefined default activity by ID.""" 495 608 for activity in DEFAULT_ACTIVITIES: ··· 539 652 description: str | None = None, 540 653 instructions: str | None = None, 541 654 priority: str = "normal", 655 + emoji: str | None = None, 542 656 icon: str | None = None, 543 657 ) -> dict[str, Any]: 544 658 """Add an activity to a facet. ··· 553 667 description: Activity description 554 668 instructions: Detection/level instructions for the LLM 555 669 priority: "high", "normal", or "low" 556 - icon: Emoji icon 670 + emoji: Glyph fallback 671 + icon: Lucide icon name 557 672 558 673 Returns: 559 674 The added activity dict ··· 589 704 activity["instructions"] = instructions 590 705 if priority and priority != "normal": 591 706 activity["priority"] = priority 707 + if emoji: 708 + activity["emoji"] = emoji 592 709 if icon: 593 710 activity["icon"] = icon 594 711 ··· 630 747 instructions: str | None = None, 631 748 priority: str | None = None, 632 749 name: str | None = None, 750 + emoji: str | None = None, 633 751 icon: str | None = None, 634 752 ) -> dict[str, Any] | None: 635 753 """Update an activity's configuration in a facet. ··· 641 759 instructions: New detection/level instructions (None to keep existing) 642 760 priority: New priority (None to keep existing) 643 761 name: New name - only applies to custom activities 644 - icon: New icon - only applies to custom activities 762 + emoji: New glyph fallback - only applies to custom activities 763 + icon: New Lucide icon name - only applies to custom activities 645 764 646 765 Returns: 647 766 Updated activity dict, or None if not found ··· 670 789 else: 671 790 activity["priority"] = priority 672 791 673 - # Only allow name/icon changes for custom activities 792 + # Only allow name/emoji/icon changes for custom activities 674 793 if activity.get("custom") or activity_id not in defaults_by_id: 675 794 if name is not None: 676 795 activity["name"] = name 796 + if emoji is not None: 797 + if emoji: 798 + activity["emoji"] = emoji 799 + else: 800 + activity.pop("emoji", None) 677 801 if icon is not None: 678 - activity["icon"] = icon 802 + if icon: 803 + activity["icon"] = icon 804 + else: 805 + activity.pop("icon", None) 679 806 680 807 break 681 808
+75 -25
tests/baselines/api/settings/activities-defaults.json
··· 3 3 { 4 4 "always_on": true, 5 5 "description": "Chat, Slack, Discord, and text messaging", 6 - "icon": "💬", 6 + "emoji": "💬", 7 + "icon": "messages-square", 8 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\" /> <path d=\"M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1\" /> </svg>", 7 9 "id": "messaging", 8 10 "instructions": "Levels: high=active conversation, medium=reading messages, low=chat app visible but idle. Detect via: chat app UI, message notifications, typing indicators.", 9 11 "name": "Messaging" ··· 11 13 { 12 14 "always_on": true, 13 15 "description": "Email reading and composition", 14 - "icon": "📧", 16 + "emoji": "📧", 17 + "icon": "mail", 18 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7\" /> <rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\" /> </svg>", 15 19 "id": "email", 16 20 "instructions": "Levels: high=composing or actively reading email, medium=scanning inbox, low=email client visible but idle. Detect via: email client UI, inbox view, compose window.", 17 21 "name": "Email" ··· 19 23 { 20 24 "always_on": true, 21 25 "description": "Video calls, in-person meetings, and conferences", 22 - "icon": "📅", 26 + "emoji": "📅", 27 + "icon": "users", 28 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\" /> <path d=\"M16 3.128a4 4 0 0 1 0 7.744\" /> <path d=\"M22 21v-2a4 4 0 0 0-3-3.87\" /> <circle cx=\"9\" cy=\"7\" r=\"4\" /> </svg>", 23 29 "id": "meeting", 24 30 "instructions": "Levels: high=actively speaking/presenting, medium=listening attentively, low=muted or multitasking during call. Detect via: video call UI, multiple speakers, calendar event visible.", 25 31 "name": "Meetings" 26 32 }, 27 33 { 28 34 "description": "Books, PDFs, articles, highlights, and documentation", 29 - "icon": "📖", 35 + "emoji": "📖", 36 + "icon": "book-open", 37 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 7v14\" /> <path d=\"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z\" /> </svg>", 30 38 "id": "reading", 31 39 "instructions": "Levels: high=focused reading, medium=skimming content, low=document open but attention elsewhere. Detect via: PDF viewers, article pages, documentation sites, reading apps, imported book highlights and annotations. Do not use for reading code — that is coding.", 32 40 "name": "Reading" 33 41 }, 34 42 { 35 43 "description": "Command line and shell sessions", 36 - "icon": "⌨️", 44 + "emoji": "⌨️", 45 + "icon": "terminal", 46 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 19h8\" /> <path d=\"m4 17 6-6-6-6\" /> </svg>", 37 47 "id": "terminal", 38 48 "instructions": "Levels: high=running commands or scripts, medium=reading output, low=terminal open but idle. Detect via: shell prompts, command output, tmux/screen sessions. If terminal use is clearly coding-related, prefer coding instead.", 39 49 "name": "Terminal" 40 50 }, 41 51 { 42 52 "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini", 43 - "icon": "🤖", 53 + "emoji": "🤖", 54 + "icon": "bot", 55 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 8V4H8\" /> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" /> </svg>", 44 56 "id": "ai_conversation", 45 57 "instructions": "Levels: high=actively prompting and reading responses, medium=reviewing AI output or refining prompts, low=AI chat open but idle. Detect via: AI assistant interfaces (ChatGPT, Claude, Gemini), imported AI conversation transcripts, prompt-response patterns. Do not confuse with messaging — AI conversation involves a human interacting with an AI model, not person-to-person chat.", 46 58 "name": "AI Conversation" 47 59 }, 48 60 { 49 61 "description": "Design tools and image editing", 50 - "icon": "🎨", 62 + "emoji": "🎨", 63 + "icon": "palette", 64 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" /> </svg>", 51 65 "id": "design", 52 66 "instructions": "Levels: high=actively creating or editing, medium=reviewing designs, low=design tool open but idle. Detect via: design apps (Figma, Photoshop, etc), canvas editing.", 53 67 "name": "Design" 54 68 }, 55 69 { 56 70 "description": "Documents, notes, and long-form writing", 57 - "icon": "✍️", 71 + "emoji": "✍️", 72 + "icon": "pencil-line", 73 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M13 21h8\" /> <path d=\"m15 5 4 4\" /> <path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" /> </svg>", 58 74 "id": "writing", 59 75 "instructions": "Levels: high=actively composing text, medium=editing/revising, low=document open but not being edited. Detect via: document editors, note apps, text content changing.", 60 76 "name": "Writing" 61 77 }, 62 78 { 63 79 "description": "Games and entertainment", 64 - "icon": "🎮", 80 + "emoji": "🎮", 81 + "icon": "gamepad-2", 82 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <line x1=\"6\" x2=\"10\" y1=\"11\" y2=\"11\" /> <line x1=\"8\" x2=\"8\" y1=\"9\" y2=\"13\" /> <line x1=\"15\" x2=\"15.01\" y1=\"12\" y2=\"12\" /> <line x1=\"18\" x2=\"18.01\" y1=\"10\" y2=\"10\" /> <path d=\"M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z\" /> </svg>", 65 83 "id": "gaming", 66 84 "instructions": "Levels: high=actively playing, medium=in menus or waiting, low=game open but tabbed out. Detect via: game window, controller input, game UI elements.", 67 85 "name": "Gaming" 68 86 }, 69 87 { 70 88 "description": "Music listening and audio", 71 - "icon": "🎵", 89 + "emoji": "🎵", 90 + "icon": "music", 91 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M9 18V5l12-2v13\" /> <circle cx=\"6\" cy=\"18\" r=\"3\" /> <circle cx=\"18\" cy=\"16\" r=\"3\" /> </svg>", 72 92 "id": "music", 73 93 "instructions": "Levels: high=actively choosing or browsing music, medium=playlist running while working, low=ambient background audio. Detect via: music player UI, audio playback indicators.", 74 94 "name": "Music" 75 95 }, 76 96 { 77 97 "description": "Programming, code review, and debugging", 78 - "icon": "💻", 98 + "emoji": "💻", 99 + "icon": "code-xml", 100 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m18 16 4-4-4-4\" /> <path d=\"m6 8-4 4 4 4\" /> <path d=\"m14.5 4-5 16\" /> </svg>", 79 101 "id": "coding", 80 102 "instructions": "Levels: high=writing or debugging code, medium=reading/reviewing code, low=IDE or editor open but not focused. Detect via: editors, terminals with dev tools, AI coding assistants, git operations. Includes focused code reading and thinking.", 81 103 "name": "Coding" 82 104 }, 83 105 { 84 106 "description": "Scheduling, calendar management, meeting preparation, and agenda setting", 85 - "icon": "📋", 107 + "emoji": "📋", 108 + "icon": "calendar-check", 109 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M8 2v4\" /> <path d=\"M16 2v4\" /> <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\" /> <path d=\"M3 10h18\" /> <path d=\"m9 16 2 2 4-4\" /> </svg>", 86 110 "id": "planning", 87 111 "instructions": "Levels: high=actively scheduling or preparing agendas, medium=reviewing calendar or event details, low=calendar visible but not being interacted with. Detect via: calendar apps, scheduling interfaces, event creation, imported calendar events, meeting invitations, agenda drafting. Use for scheduling and preparation work. Do not confuse with meeting — planning is the preparation, meeting is the actual synchronous interaction.", 88 112 "name": "Planning" 89 113 }, 90 114 { 91 115 "description": "Social media browsing and interaction", 92 - "icon": "📱", 116 + "emoji": "📱", 117 + "icon": "share-2", 118 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"18\" cy=\"5\" r=\"3\" /> <circle cx=\"6\" cy=\"12\" r=\"3\" /> <circle cx=\"18\" cy=\"19\" r=\"3\" /> <line x1=\"8.59\" x2=\"15.42\" y1=\"13.51\" y2=\"17.49\" /> <line x1=\"15.41\" x2=\"8.59\" y1=\"6.51\" y2=\"10.49\" /> </svg>", 93 119 "id": "social", 94 120 "instructions": "Levels: high=posting or actively engaging, medium=scrolling feed, low=social app open but idle. Detect via: social media sites/apps, feed content, post composition.", 95 121 "name": "Social Media" 96 122 }, 97 123 { 98 124 "description": "Spreadsheets, slides, and task management", 99 - "icon": "📊", 125 + "emoji": "📊", 126 + "icon": "chart-column", 127 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M3 3v16a2 2 0 0 0 2 2h16\" /> <path d=\"M18 17V9\" /> <path d=\"M13 17V5\" /> <path d=\"M8 17v-3\" /> </svg>", 100 128 "id": "productivity", 101 129 "instructions": "Levels: high=actively editing or organizing, medium=reviewing data, low=app open but not focused. Detect via: spreadsheet/slide editors, project management tools, task boards.", 102 130 "name": "Productivity" 103 131 }, 104 132 { 105 133 "description": "Watching videos and streaming content", 106 - "icon": "🎬", 134 + "emoji": "🎬", 135 + "icon": "clapperboard", 136 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m12.296 3.464 3.02 3.956\" /> <path d=\"M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z\" /> <path d=\"M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" /> <path d=\"m6.18 5.276 3.1 3.899\" /> </svg>", 107 137 "id": "video", 108 138 "instructions": "Levels: high=actively watching, medium=video playing while doing something else, low=video paused or minimized. Detect via: video player UI, streaming sites, playback controls.", 109 139 "name": "Video" 110 140 }, 111 141 { 112 142 "description": "Web browsing, research, and reading online", 113 - "icon": "🌐", 143 + "emoji": "🌐", 144 + "icon": "globe", 145 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" /> <path d=\"M2 12h20\" /> </svg>", 114 146 "id": "browsing", 115 147 "instructions": "Levels: high=actively navigating/researching, medium=reading a page, low=browser open but idle. Detect via: browser tabs, URL changes, search queries.", 116 148 "name": "Browsing" 117 149 }, 118 150 { 119 151 "description": "a call you have planned", 120 - "icon": "📞", 152 + "emoji": "📞", 153 + "icon": "phone", 154 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384\" /> </svg>", 121 155 "id": "call", 122 156 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 123 157 "name": "call" 124 158 }, 125 159 { 126 160 "description": "a celebration on the calendar", 127 - "icon": "🎉", 161 + "emoji": "🎉", 162 + "icon": "party-popper", 163 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M5.8 11.3 2 22l10.7-3.79\" /> <path d=\"M4 3h.01\" /> <path d=\"M22 8h.01\" /> <path d=\"M15 2h.01\" /> <path d=\"M22 20h.01\" /> <path d=\"m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10\" /> <path d=\"m22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11c-.11.7-.72 1.22-1.43 1.22H17\" /> <path d=\"m11 2 .33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7\" /> <path d=\"M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z\" /> </svg>", 128 164 "id": "celebration", 129 165 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 130 166 "name": "celebration" 131 167 }, 132 168 { 133 169 "description": "a deadline you are working toward", 134 - "icon": "⏰", 170 + "emoji": "⏰", 171 + "icon": "alarm-clock", 172 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"12\" cy=\"13\" r=\"8\" /> <path d=\"M12 9v4l2 2\" /> <path d=\"M5 3 2 6\" /> <path d=\"m22 6-3-3\" /> <path d=\"M6.38 18.7 4 21\" /> <path d=\"M17.64 18.67 20 21\" /> </svg>", 135 173 "id": "deadline", 136 174 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 137 175 "name": "deadline" 138 176 }, 139 177 { 140 178 "description": "a medical appointment on your calendar", 141 - "icon": "🩺", 179 + "emoji": "🩺", 180 + "icon": "stethoscope", 181 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M11 2v2\" /> <path d=\"M5 2v2\" /> <path d=\"M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1\" /> <path d=\"M8 15a6 6 0 0 0 12 0v-3\" /> <circle cx=\"20\" cy=\"10\" r=\"2\" /> </svg>", 142 182 "id": "doctor_appointment", 143 183 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 144 184 "name": "doctor appointment" 145 185 }, 146 186 { 147 187 "description": "a reminder for something upcoming", 148 - "icon": "🔔", 188 + "emoji": "🔔", 189 + "icon": "bell", 190 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M10.268 21a2 2 0 0 0 3.464 0\" /> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\" /> </svg>", 149 191 "id": "reminder", 150 192 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 151 193 "name": "reminder" 152 194 }, 153 195 { 154 196 "description": "an appointment on your calendar", 155 - "icon": "📌", 197 + "emoji": "📌", 198 + "icon": "pin", 199 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 17v5\" /> <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\" /> </svg>", 156 200 "id": "appointment", 157 201 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 158 202 "name": "appointment" 159 203 }, 160 204 { 161 205 "description": "an errand you plan to do", 162 - "icon": "🧾", 206 + "emoji": "🧾", 207 + "icon": "receipt", 208 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 17V7\" /> <path d=\"M16 8h-6a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H8\" /> <path d=\"M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z\" /> </svg>", 163 209 "id": "errand", 164 210 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 165 211 "name": "errand" 166 212 }, 167 213 { 168 214 "description": "an event you plan to attend", 169 - "icon": "🎟️", 215 + "emoji": "🎟️", 216 + "icon": "ticket", 217 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z\" /> <path d=\"M13 5v2\" /> <path d=\"M13 17v2\" /> <path d=\"M13 11v2\" /> </svg>", 170 218 "id": "event", 171 219 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 172 220 "name": "event" 173 221 }, 174 222 { 175 223 "description": "travel you have planned", 176 - "icon": "✈️", 224 + "emoji": "✈️", 225 + "icon": "plane", 226 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z\" /> </svg>", 177 227 "id": "travel", 178 228 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 179 229 "name": "travel"
+85 -28
tests/baselines/api/settings/facet-activities.json
··· 4 4 "always_on": true, 5 5 "custom": false, 6 6 "description": "Chat, Slack, Discord, and text messaging", 7 - "icon": "💬", 7 + "emoji": "💬", 8 + "icon": "messages-square", 9 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\" /> <path d=\"M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1\" /> </svg>", 8 10 "id": "messaging", 9 11 "instructions": "Levels: high=active conversation, medium=reading messages, low=chat app visible but idle. Detect via: chat app UI, message notifications, typing indicators.", 10 12 "name": "Messaging", ··· 14 16 "always_on": true, 15 17 "custom": false, 16 18 "description": "Email reading and composition", 17 - "icon": "📧", 19 + "emoji": "📧", 20 + "icon": "mail", 21 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7\" /> <rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\" /> </svg>", 18 22 "id": "email", 19 23 "instructions": "Levels: high=composing or actively reading email, medium=scanning inbox, low=email client visible but idle. Detect via: email client UI, inbox view, compose window.", 20 24 "name": "Email", ··· 24 28 "always_on": true, 25 29 "custom": false, 26 30 "description": "Meetings and sync calls", 27 - "icon": "📅", 31 + "emoji": "📅", 32 + "icon": "users", 33 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\" /> <path d=\"M16 3.128a4 4 0 0 1 0 7.744\" /> <path d=\"M22 21v-2a4 4 0 0 0-3-3.87\" /> <circle cx=\"9\" cy=\"7\" r=\"4\" /> </svg>", 28 34 "id": "meeting", 29 35 "instructions": "Levels: high=actively speaking/presenting, medium=listening attentively, low=muted or multitasking during call. Detect via: video call UI, multiple speakers, calendar event visible.", 30 36 "name": "Meetings", ··· 33 39 { 34 40 "custom": true, 35 41 "description": "Software development and coding", 42 + "icon_svg": null, 36 43 "id": "engineering", 37 44 "name": "Engineering", 38 45 "priority": "high" ··· 42 49 { 43 50 "always_on": true, 44 51 "description": "Chat, Slack, Discord, and text messaging", 45 - "icon": "💬", 52 + "emoji": "💬", 53 + "icon": "messages-square", 54 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\" /> <path d=\"M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1\" /> </svg>", 46 55 "id": "messaging", 47 56 "instructions": "Levels: high=active conversation, medium=reading messages, low=chat app visible but idle. Detect via: chat app UI, message notifications, typing indicators.", 48 57 "name": "Messaging" ··· 50 59 { 51 60 "always_on": true, 52 61 "description": "Email reading and composition", 53 - "icon": "📧", 62 + "emoji": "📧", 63 + "icon": "mail", 64 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7\" /> <rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\" /> </svg>", 54 65 "id": "email", 55 66 "instructions": "Levels: high=composing or actively reading email, medium=scanning inbox, low=email client visible but idle. Detect via: email client UI, inbox view, compose window.", 56 67 "name": "Email" ··· 58 69 { 59 70 "always_on": true, 60 71 "description": "Video calls, in-person meetings, and conferences", 61 - "icon": "📅", 72 + "emoji": "📅", 73 + "icon": "users", 74 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\" /> <path d=\"M16 3.128a4 4 0 0 1 0 7.744\" /> <path d=\"M22 21v-2a4 4 0 0 0-3-3.87\" /> <circle cx=\"9\" cy=\"7\" r=\"4\" /> </svg>", 62 75 "id": "meeting", 63 76 "instructions": "Levels: high=actively speaking/presenting, medium=listening attentively, low=muted or multitasking during call. Detect via: video call UI, multiple speakers, calendar event visible.", 64 77 "name": "Meetings" 65 78 }, 66 79 { 67 80 "description": "Books, PDFs, articles, highlights, and documentation", 68 - "icon": "📖", 81 + "emoji": "📖", 82 + "icon": "book-open", 83 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 7v14\" /> <path d=\"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z\" /> </svg>", 69 84 "id": "reading", 70 85 "instructions": "Levels: high=focused reading, medium=skimming content, low=document open but attention elsewhere. Detect via: PDF viewers, article pages, documentation sites, reading apps, imported book highlights and annotations. Do not use for reading code — that is coding.", 71 86 "name": "Reading" 72 87 }, 73 88 { 74 89 "description": "Command line and shell sessions", 75 - "icon": "⌨️", 90 + "emoji": "⌨️", 91 + "icon": "terminal", 92 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 19h8\" /> <path d=\"m4 17 6-6-6-6\" /> </svg>", 76 93 "id": "terminal", 77 94 "instructions": "Levels: high=running commands or scripts, medium=reading output, low=terminal open but idle. Detect via: shell prompts, command output, tmux/screen sessions. If terminal use is clearly coding-related, prefer coding instead.", 78 95 "name": "Terminal" 79 96 }, 80 97 { 81 98 "description": "Conversations with AI assistants like ChatGPT, Claude, and Gemini", 82 - "icon": "🤖", 99 + "emoji": "🤖", 100 + "icon": "bot", 101 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 8V4H8\" /> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" /> </svg>", 83 102 "id": "ai_conversation", 84 103 "instructions": "Levels: high=actively prompting and reading responses, medium=reviewing AI output or refining prompts, low=AI chat open but idle. Detect via: AI assistant interfaces (ChatGPT, Claude, Gemini), imported AI conversation transcripts, prompt-response patterns. Do not confuse with messaging — AI conversation involves a human interacting with an AI model, not person-to-person chat.", 85 104 "name": "AI Conversation" 86 105 }, 87 106 { 88 107 "description": "Design tools and image editing", 89 - "icon": "🎨", 108 + "emoji": "🎨", 109 + "icon": "palette", 110 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" /> </svg>", 90 111 "id": "design", 91 112 "instructions": "Levels: high=actively creating or editing, medium=reviewing designs, low=design tool open but idle. Detect via: design apps (Figma, Photoshop, etc), canvas editing.", 92 113 "name": "Design" 93 114 }, 94 115 { 95 116 "description": "Documents, notes, and long-form writing", 96 - "icon": "✍️", 117 + "emoji": "✍️", 118 + "icon": "pencil-line", 119 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M13 21h8\" /> <path d=\"m15 5 4 4\" /> <path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" /> </svg>", 97 120 "id": "writing", 98 121 "instructions": "Levels: high=actively composing text, medium=editing/revising, low=document open but not being edited. Detect via: document editors, note apps, text content changing.", 99 122 "name": "Writing" 100 123 }, 101 124 { 102 125 "description": "Games and entertainment", 103 - "icon": "🎮", 126 + "emoji": "🎮", 127 + "icon": "gamepad-2", 128 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <line x1=\"6\" x2=\"10\" y1=\"11\" y2=\"11\" /> <line x1=\"8\" x2=\"8\" y1=\"9\" y2=\"13\" /> <line x1=\"15\" x2=\"15.01\" y1=\"12\" y2=\"12\" /> <line x1=\"18\" x2=\"18.01\" y1=\"10\" y2=\"10\" /> <path d=\"M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z\" /> </svg>", 104 129 "id": "gaming", 105 130 "instructions": "Levels: high=actively playing, medium=in menus or waiting, low=game open but tabbed out. Detect via: game window, controller input, game UI elements.", 106 131 "name": "Gaming" 107 132 }, 108 133 { 109 134 "description": "Music listening and audio", 110 - "icon": "🎵", 135 + "emoji": "🎵", 136 + "icon": "music", 137 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M9 18V5l12-2v13\" /> <circle cx=\"6\" cy=\"18\" r=\"3\" /> <circle cx=\"18\" cy=\"16\" r=\"3\" /> </svg>", 111 138 "id": "music", 112 139 "instructions": "Levels: high=actively choosing or browsing music, medium=playlist running while working, low=ambient background audio. Detect via: music player UI, audio playback indicators.", 113 140 "name": "Music" 114 141 }, 115 142 { 116 143 "description": "Programming, code review, and debugging", 117 - "icon": "💻", 144 + "emoji": "💻", 145 + "icon": "code-xml", 146 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m18 16 4-4-4-4\" /> <path d=\"m6 8-4 4 4 4\" /> <path d=\"m14.5 4-5 16\" /> </svg>", 118 147 "id": "coding", 119 148 "instructions": "Levels: high=writing or debugging code, medium=reading/reviewing code, low=IDE or editor open but not focused. Detect via: editors, terminals with dev tools, AI coding assistants, git operations. Includes focused code reading and thinking.", 120 149 "name": "Coding" 121 150 }, 122 151 { 123 152 "description": "Scheduling, calendar management, meeting preparation, and agenda setting", 124 - "icon": "📋", 153 + "emoji": "📋", 154 + "icon": "calendar-check", 155 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M8 2v4\" /> <path d=\"M16 2v4\" /> <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\" /> <path d=\"M3 10h18\" /> <path d=\"m9 16 2 2 4-4\" /> </svg>", 125 156 "id": "planning", 126 157 "instructions": "Levels: high=actively scheduling or preparing agendas, medium=reviewing calendar or event details, low=calendar visible but not being interacted with. Detect via: calendar apps, scheduling interfaces, event creation, imported calendar events, meeting invitations, agenda drafting. Use for scheduling and preparation work. Do not confuse with meeting — planning is the preparation, meeting is the actual synchronous interaction.", 127 158 "name": "Planning" 128 159 }, 129 160 { 130 161 "description": "Social media browsing and interaction", 131 - "icon": "📱", 162 + "emoji": "📱", 163 + "icon": "share-2", 164 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"18\" cy=\"5\" r=\"3\" /> <circle cx=\"6\" cy=\"12\" r=\"3\" /> <circle cx=\"18\" cy=\"19\" r=\"3\" /> <line x1=\"8.59\" x2=\"15.42\" y1=\"13.51\" y2=\"17.49\" /> <line x1=\"15.41\" x2=\"8.59\" y1=\"6.51\" y2=\"10.49\" /> </svg>", 132 165 "id": "social", 133 166 "instructions": "Levels: high=posting or actively engaging, medium=scrolling feed, low=social app open but idle. Detect via: social media sites/apps, feed content, post composition.", 134 167 "name": "Social Media" 135 168 }, 136 169 { 137 170 "description": "Spreadsheets, slides, and task management", 138 - "icon": "📊", 171 + "emoji": "📊", 172 + "icon": "chart-column", 173 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M3 3v16a2 2 0 0 0 2 2h16\" /> <path d=\"M18 17V9\" /> <path d=\"M13 17V5\" /> <path d=\"M8 17v-3\" /> </svg>", 139 174 "id": "productivity", 140 175 "instructions": "Levels: high=actively editing or organizing, medium=reviewing data, low=app open but not focused. Detect via: spreadsheet/slide editors, project management tools, task boards.", 141 176 "name": "Productivity" 142 177 }, 143 178 { 144 179 "description": "Watching videos and streaming content", 145 - "icon": "🎬", 180 + "emoji": "🎬", 181 + "icon": "clapperboard", 182 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m12.296 3.464 3.02 3.956\" /> <path d=\"M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z\" /> <path d=\"M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" /> <path d=\"m6.18 5.276 3.1 3.899\" /> </svg>", 146 183 "id": "video", 147 184 "instructions": "Levels: high=actively watching, medium=video playing while doing something else, low=video paused or minimized. Detect via: video player UI, streaming sites, playback controls.", 148 185 "name": "Video" 149 186 }, 150 187 { 151 188 "description": "Web browsing, research, and reading online", 152 - "icon": "🌐", 189 + "emoji": "🌐", 190 + "icon": "globe", 191 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" /> <path d=\"M2 12h20\" /> </svg>", 153 192 "id": "browsing", 154 193 "instructions": "Levels: high=actively navigating/researching, medium=reading a page, low=browser open but idle. Detect via: browser tabs, URL changes, search queries.", 155 194 "name": "Browsing" 156 195 }, 157 196 { 158 197 "description": "a call you have planned", 159 - "icon": "📞", 198 + "emoji": "📞", 199 + "icon": "phone", 200 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384\" /> </svg>", 160 201 "id": "call", 161 202 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 162 203 "name": "call" 163 204 }, 164 205 { 165 206 "description": "a celebration on the calendar", 166 - "icon": "🎉", 207 + "emoji": "🎉", 208 + "icon": "party-popper", 209 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M5.8 11.3 2 22l10.7-3.79\" /> <path d=\"M4 3h.01\" /> <path d=\"M22 8h.01\" /> <path d=\"M15 2h.01\" /> <path d=\"M22 20h.01\" /> <path d=\"m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10\" /> <path d=\"m22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11c-.11.7-.72 1.22-1.43 1.22H17\" /> <path d=\"m11 2 .33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7\" /> <path d=\"M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z\" /> </svg>", 167 210 "id": "celebration", 168 211 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 169 212 "name": "celebration" 170 213 }, 171 214 { 172 215 "description": "a deadline you are working toward", 173 - "icon": "⏰", 216 + "emoji": "⏰", 217 + "icon": "alarm-clock", 218 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"12\" cy=\"13\" r=\"8\" /> <path d=\"M12 9v4l2 2\" /> <path d=\"M5 3 2 6\" /> <path d=\"m22 6-3-3\" /> <path d=\"M6.38 18.7 4 21\" /> <path d=\"M17.64 18.67 20 21\" /> </svg>", 174 219 "id": "deadline", 175 220 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 176 221 "name": "deadline" 177 222 }, 178 223 { 179 224 "description": "a medical appointment on your calendar", 180 - "icon": "🩺", 225 + "emoji": "🩺", 226 + "icon": "stethoscope", 227 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M11 2v2\" /> <path d=\"M5 2v2\" /> <path d=\"M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1\" /> <path d=\"M8 15a6 6 0 0 0 12 0v-3\" /> <circle cx=\"20\" cy=\"10\" r=\"2\" /> </svg>", 181 228 "id": "doctor_appointment", 182 229 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 183 230 "name": "doctor appointment" 184 231 }, 185 232 { 186 233 "description": "a reminder for something upcoming", 187 - "icon": "🔔", 234 + "emoji": "🔔", 235 + "icon": "bell", 236 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M10.268 21a2 2 0 0 0 3.464 0\" /> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\" /> </svg>", 188 237 "id": "reminder", 189 238 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 190 239 "name": "reminder" 191 240 }, 192 241 { 193 242 "description": "an appointment on your calendar", 194 - "icon": "📌", 243 + "emoji": "📌", 244 + "icon": "pin", 245 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 17v5\" /> <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\" /> </svg>", 195 246 "id": "appointment", 196 247 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 197 248 "name": "appointment" 198 249 }, 199 250 { 200 251 "description": "an errand you plan to do", 201 - "icon": "🧾", 252 + "emoji": "🧾", 253 + "icon": "receipt", 254 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 17V7\" /> <path d=\"M16 8h-6a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H8\" /> <path d=\"M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z\" /> </svg>", 202 255 "id": "errand", 203 256 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 204 257 "name": "errand" 205 258 }, 206 259 { 207 260 "description": "an event you plan to attend", 208 - "icon": "🎟️", 261 + "emoji": "🎟️", 262 + "icon": "ticket", 263 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z\" /> <path d=\"M13 5v2\" /> <path d=\"M13 17v2\" /> <path d=\"M13 11v2\" /> </svg>", 209 264 "id": "event", 210 265 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 211 266 "name": "event" 212 267 }, 213 268 { 214 269 "description": "travel you have planned", 215 - "icon": "✈️", 270 + "emoji": "✈️", 271 + "icon": "plane", 272 + "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z\" /> </svg>", 216 273 "id": "travel", 217 274 "instructions": "Scheduled events emitted by talent/schedule.md; not detected from sense data.", 218 275 "name": "travel"
+99
tests/test_activities.py
··· 25 25 assert "id" in activity 26 26 assert "name" in activity 27 27 assert "description" in activity 28 + assert "emoji" in activity 29 + assert "icon" in activity 28 30 29 31 # Check some known activities exist 30 32 ids = [a["id"] for a in defaults] ··· 37 39 assert "instructions" in activity, f"{activity['id']} missing instructions" 38 40 assert isinstance(activity["instructions"], str) 39 41 assert len(activity["instructions"]) > 0 42 + 43 + 44 + def test_default_activities_have_canonical_icon_shape(): 45 + """Default activities keep emoji fallback and explicit Lucide icon name.""" 46 + from solstone.think.activities import DEFAULT_ACTIVITIES, LUCIDE_ICON_NAME_RE 47 + 48 + assert len(DEFAULT_ACTIVITIES) == 25 49 + for activity in DEFAULT_ACTIVITIES: 50 + assert activity["emoji"] 51 + assert LUCIDE_ICON_NAME_RE.fullmatch(activity["icon"]) 40 52 41 53 42 54 def test_get_default_activities_returns_copy(): ··· 204 216 "description": "A custom activity", 205 217 "instructions": "Custom activity detection hints", 206 218 "custom": True, 219 + "emoji": "🎯", 220 + "icon": "sparkles", 207 221 }, 208 222 ] 209 223 save_facet_activities("test_facet", activities) ··· 243 257 assert custom["custom"] is True 244 258 assert custom["name"] == "Custom" 245 259 assert custom["instructions"] == "Custom activity detection hints" 260 + assert custom["emoji"] == "🎯" 261 + assert custom["icon"] == "sparkles" 262 + 263 + 264 + def test_get_facet_activities_normalizes_legacy_custom_icon_without_writing( 265 + monkeypatch, 266 + ): 267 + """Legacy custom records with glyph in icon are normalized in memory only.""" 268 + from solstone.think.activities import get_facet_activities 269 + 270 + with tempfile.TemporaryDirectory() as tmpdir: 271 + monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 272 + activities_dir = Path(tmpdir) / "facets" / "work" / "activities" 273 + activities_dir.mkdir(parents=True) 274 + activities_file = activities_dir / "activities.jsonl" 275 + activities_file.write_text( 276 + json.dumps( 277 + { 278 + "id": "legacy_custom", 279 + "custom": True, 280 + "name": "Legacy custom", 281 + "description": "Old stored shape", 282 + "icon": "🎯", 283 + } 284 + ) 285 + + "\n", 286 + encoding="utf-8", 287 + ) 288 + 289 + activities = get_facet_activities("work") 290 + 291 + legacy = next( 292 + activity for activity in activities if activity["id"] == "legacy_custom" 293 + ) 294 + assert legacy["emoji"] == "🎯" 295 + assert "icon" not in legacy 296 + assert '"icon":' in activities_file.read_text(encoding="utf-8") 297 + assert '"emoji":' not in activities_file.read_text(encoding="utf-8") 298 + 299 + 300 + def test_migrate_custom_activity_icons_to_emoji_is_idempotent(monkeypatch): 301 + from solstone.think.activities import migrate_custom_activity_icons_to_emoji 302 + 303 + with tempfile.TemporaryDirectory() as tmpdir: 304 + monkeypatch.setenv("SOLSTONE_JOURNAL", tmpdir) 305 + activities_dir = Path(tmpdir) / "facets" / "work" / "activities" 306 + activities_dir.mkdir(parents=True) 307 + activities_file = activities_dir / "activities.jsonl" 308 + activities_file.write_text( 309 + json.dumps( 310 + { 311 + "id": "legacy_custom", 312 + "custom": True, 313 + "name": "Legacy custom", 314 + "description": "Old stored shape", 315 + "icon": "🎯", 316 + } 317 + ) 318 + + "\n", 319 + encoding="utf-8", 320 + ) 321 + 322 + first = migrate_custom_activity_icons_to_emoji() 323 + second = migrate_custom_activity_icons_to_emoji() 324 + 325 + assert first["files_scanned"] == 1 326 + assert first["files_changed"] == 1 327 + assert first["records_changed"] == 1 328 + assert second["files_scanned"] == 1 329 + assert second["files_changed"] == 0 330 + assert second["records_changed"] == 0 331 + 332 + records = [ 333 + json.loads(line) 334 + for line in activities_file.read_text(encoding="utf-8").splitlines() 335 + ] 336 + assert records == [ 337 + { 338 + "id": "legacy_custom", 339 + "custom": True, 340 + "name": "Legacy custom", 341 + "description": "Old stored shape", 342 + "emoji": "🎯", 343 + } 344 + ] 246 345 247 346 248 347 def test_add_activity_to_facet(monkeypatch):
+105 -16
tests/test_app_activities.py
··· 8 8 import pytest 9 9 10 10 from solstone.apps.activities.routes import ( 11 - _GENERIC_ACTIVITY_ICON, 11 + _GENERIC_ACTIVITY_EMOJI, 12 + _GENERIC_ACTIVITY_LUCIDE, 12 13 _enrich_activity_record, 13 14 activities_bp, 14 15 ) 16 + from solstone.convey.icons import lucide_svg 15 17 16 18 17 19 @pytest.fixture ··· 63 65 data = resp.get_json()["items"] 64 66 coding = next(a for a in data if a["activity"] == "coding") 65 67 assert coding["name"] != "" 68 + assert coding["emoji"] != "" 66 69 assert coding["icon"] != "" 70 + assert "<svg" in coding["icon_svg"] 67 71 68 72 def test_returns_mixed_anticipated_and_realized_records(self, activities_client): 69 73 resp = activities_client.get( ··· 96 100 assert call["endTime"] == "2026-04-22T15:30:00" 97 101 assert call["duration_minutes"] == 90 98 102 assert call["name"] == "call" 99 - assert call["icon"] == "📞" 103 + assert call["emoji"] == "📞" 104 + assert call["icon"] == "phone" 105 + assert call["icon_svg"] == lucide_svg("phone") 100 106 101 107 deadline = by_id["anticipated_deadline_170000_0422"] 102 108 assert deadline["startTime"] == "2026-04-22T17:00:00" 103 109 assert "endTime" not in deadline 104 110 assert "duration_minutes" not in deadline 105 - assert deadline["icon"] == "⏰" 111 + assert deadline["emoji"] == "⏰" 112 + assert deadline["icon"] == "alarm-clock" 113 + assert deadline["icon_svg"] == lucide_svg("alarm-clock") 106 114 107 115 appointment = by_id["anticipated_appointment_000000_0422"] 108 116 assert "startTime" not in appointment 109 117 assert "endTime" not in appointment 110 118 assert "duration_minutes" not in appointment 111 - assert appointment["icon"] == "📌" 119 + assert appointment["emoji"] == "📌" 120 + assert appointment["icon"] == "pin" 121 + assert appointment["icon_svg"] == lucide_svg("pin") 112 122 113 123 unknown = by_id["anticipated_made_up_type_000000_0422"] 114 124 assert unknown["name"] == "made_up_type" 115 - assert unknown["icon"] == _GENERIC_ACTIVITY_ICON 125 + assert unknown["emoji"] == _GENERIC_ACTIVITY_EMOJI 126 + assert unknown["icon"] == _GENERIC_ACTIVITY_LUCIDE 127 + assert unknown["icon_svg"] == lucide_svg(_GENERIC_ACTIVITY_LUCIDE) 116 128 assert "duration_minutes" not in unknown 117 129 118 130 def test_schedule_activity_defaults_are_lowercase(self): ··· 136 148 assert activity["name"] == activity["name"].lower() 137 149 138 150 @pytest.mark.parametrize( 139 - ("activity_id", "expected_name", "expected_icon"), 151 + ("activity_id", "expected_name", "expected_emoji", "expected_icon"), 140 152 [ 141 - ("call", "call", "📞"), 142 - ("deadline", "deadline", "⏰"), 143 - ("appointment", "appointment", "📌"), 144 - ("event", "event", "🎟️"), 145 - ("travel", "travel", "✈️"), 146 - ("reminder", "reminder", "🔔"), 147 - ("errand", "errand", "🧾"), 148 - ("celebration", "celebration", "🎉"), 149 - ("doctor_appointment", "doctor appointment", "🩺"), 153 + ("call", "call", "📞", "phone"), 154 + ("deadline", "deadline", "⏰", "alarm-clock"), 155 + ("appointment", "appointment", "📌", "pin"), 156 + ("event", "event", "🎟️", "ticket"), 157 + ("travel", "travel", "✈️", "plane"), 158 + ("reminder", "reminder", "🔔", "bell"), 159 + ("errand", "errand", "🧾", "receipt"), 160 + ("celebration", "celebration", "🎉", "party-popper"), 161 + ("doctor_appointment", "doctor appointment", "🩺", "stethoscope"), 150 162 ], 151 163 ) 152 164 def test_enrich_activity_record_uses_global_default_for_schedule_activity( ··· 154 166 activities_client, 155 167 activity_id, 156 168 expected_name, 169 + expected_emoji, 157 170 expected_icon, 158 171 ): 159 172 record = { ··· 170 183 171 184 assert enriched is not None 172 185 assert enriched["name"] == expected_name 186 + assert enriched["emoji"] == expected_emoji 173 187 assert enriched["icon"] == expected_icon 188 + assert enriched["icon_svg"] == lucide_svg(expected_icon) 174 189 assert enriched["duration_minutes"] == 30 175 190 176 191 def test_enrich_activity_record_uses_generic_icon_for_unknown_activity( ··· 191 206 192 207 assert enriched is not None 193 208 assert enriched["name"] == "made_up_type" 194 - assert enriched["icon"] == _GENERIC_ACTIVITY_ICON 209 + assert enriched["emoji"] == _GENERIC_ACTIVITY_EMOJI 210 + assert enriched["icon"] == _GENERIC_ACTIVITY_LUCIDE 211 + assert enriched["icon_svg"] == lucide_svg(_GENERIC_ACTIVITY_LUCIDE) 212 + assert "duration_minutes" not in enriched 213 + 214 + @pytest.mark.parametrize( 215 + ("activity_id", "stored", "expected_emoji", "expected_icon"), 216 + [ 217 + ( 218 + "custom_absent", 219 + { 220 + "id": "custom_absent", 221 + "custom": True, 222 + "name": "Custom absent", 223 + "emoji": "🎯", 224 + }, 225 + "🎯", 226 + "", 227 + ), 228 + ( 229 + "custom_unknown", 230 + { 231 + "id": "custom_unknown", 232 + "custom": True, 233 + "name": "Custom unknown", 234 + "emoji": "🎲", 235 + "icon": "not-real", 236 + }, 237 + "🎲", 238 + "not-real", 239 + ), 240 + ( 241 + "custom_legacy", 242 + { 243 + "id": "custom_legacy", 244 + "custom": True, 245 + "name": "Custom legacy", 246 + "icon": "🎨", 247 + }, 248 + "🎨", 249 + "", 250 + ), 251 + ], 252 + ) 253 + def test_enrich_activity_record_falls_back_to_stored_emoji( 254 + self, 255 + monkeypatch, 256 + tmp_path, 257 + activity_id, 258 + stored, 259 + expected_emoji, 260 + expected_icon, 261 + ): 262 + activities_dir = tmp_path / "facets" / "work" / "activities" 263 + activities_dir.mkdir(parents=True) 264 + activities_file = activities_dir / "activities.jsonl" 265 + activities_file.write_text(json.dumps(stored) + "\n", encoding="utf-8") 266 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 267 + 268 + record = { 269 + "id": f"{activity_id}_090000_0422", 270 + "activity": activity_id, 271 + "target_date": "2026-04-22", 272 + "start": None, 273 + "end": None, 274 + "description": "custom planned item", 275 + "source": "anticipated", 276 + } 277 + 278 + enriched = _enrich_activity_record(record, "work", "20260422") 279 + 280 + assert enriched is not None 281 + assert enriched["emoji"] == expected_emoji 282 + assert enriched["icon"] == expected_icon 283 + assert enriched["icon_svg"] is None 195 284 assert "duration_minutes" not in enriched 196 285 197 286 def test_enrich_activity_record_omits_duration_when_end_before_start(
+1 -1
tests/test_maint.py
··· 372 372 if task.qualified_name not in blocking_migrations | retry_migrations 373 373 ] 374 374 375 - assert len(existing) == 22 375 + assert len(existing) == 23 376 376 assert all(not task.retry_on_next_start for task in existing) 377 377 assert all(not task.blocks_supervisor_start for task in existing) 378 378