a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

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

skills as wayfinding, not gates; ship /api/users/{handle}

skill-prompt audit (the user pointed out phi was internalizing skills
as bounding her capability surface, which is the wrong frame):

- pdsx-fundamentals SKILL.md: add "important framing" paragraph
explicitly stating that domain skills don't add capabilities —
pdsx already does. skills are lighthouses for using the general
tool well; the capability surface is wider than the skill surface,
not narrower.
- cosmik-records SKILL.md: rewrite description + opening to lead with
"this skill doesn't add a capability — pdsx already lets you write
any cosmik record. what's here is the wayfinding." load isn't a
gate; it's a reference card.
- publish-blog SKILL.md: cross-reference reframed from "load the
cosmik-records skill" to "write a network.cosmik.card via pdsx (the
cosmik-records skill has the per-record-type schema details)" —
pdsx is the verb, the skill is the schema reference.
- docs/memory.md: source line + writes-paragraph tightened to match.

infra (per spec files in repo root):

- /api/users/{handle} added per USER-VIEW.md. direct read of phi's
tpuf state for a handle: counts per kind, first/last seen,
synthesized summary text + timestamp, recent observations with
source_uris for provenance, resolved DID best-effort. no embedding,
no LLM, no fabrication. 60s in-process TTL cache per handle.
- USER-VIEW.md + SKILLS-API.md tracked for the engineer working on
the cockpit.

UI-side changes (separate hand): web/src/lib/abilities.ts removed
since /api/abilities is live; capabilities/+page.svelte switched to
fetch from the endpoint; mind-lens map rework in progress
(MindMap.svelte added, Atlas.svelte removed). loq.toml bumps for
the new svelte components.

104 tests + 2 evals pass.

+1691 -793
+90
SKILLS-API.md
··· 1 + # skills + map enrichments 2 + 3 + with the skills paradigm landed (pdsx-fundamentals + cosmik-records + 4 + publish-blog) and `/api/users/{handle}` shipped, two follow-ons would 5 + unblock the cockpit's map rework. 6 + 7 + ## 1. `GET /api/skills` 8 + 9 + skills don't surface in the UI yet. they live in `bot/skills/{name}/` — 10 + each with a `SKILL.md` whose frontmatter has `name` and `description`. 11 + 12 + proposed endpoint: 13 + 14 + ```json 15 + [ 16 + { 17 + "name": "pdsx-fundamentals", 18 + "description": "How to use the pdsx MCP for atproto record CRUD on arbitrary lexicons. Load this when you want to do something on atproto that doesn't have a dedicated tool — saving to a custom lexicon, opening a tangled issue, writing a leaflet comment, etc.", 19 + "resources": ["SKILL.md"] 20 + }, 21 + { 22 + "name": "cosmik-records", 23 + "description": "How to write to cosmik (your public knowledge graph on atproto via semble). Load this when you want to save a URL, write a public note, or create a typed connection between cards. The companion skill to pdsx-fundamentals — same mechanics, applied to the network.cosmik.* lexicons.", 24 + "resources": ["SKILL.md", "CARD-NOTE.md", "CARD-URL.md", "CONNECTION.md"] 25 + }, 26 + { 27 + "name": "publish-blog", 28 + "description": "...", 29 + "resources": ["SKILL.md"] 30 + } 31 + ] 32 + ``` 33 + 34 + implementation: walk `bot/skills/`, parse the frontmatter from each 35 + `SKILL.md`, list sibling `.md` files as `resources`. cache for process 36 + lifetime; skills register at startup like tools do. 37 + 38 + read path: `SkillsToolset(directories=[settings.skills_dir])` already 39 + knows how to find them — you can probably introspect the toolset rather 40 + than re-walking the directory. 41 + 42 + UI consumer: skills get rendered on the `mind` lens as a kind of 43 + "available capability" — alongside goals (anchors) and active 44 + observations (current attention). nodes labeled by name; clicking 45 + opens a logbook entry with the description and the resource list. 46 + 47 + ## 2. enrich `/api/memory/graph` with knowledge density 48 + 49 + the new map design positions known people on a ring around phi. it 50 + wants to scale each node by **knowledge density** — count of 51 + non-superseded observations + summary presence — so dense relationships 52 + look bigger than thin ones. that signal already exists in tpuf; today 53 + the only way to get it from the UI is `/api/users/{handle}` per node, 54 + which means N extra HTTP roundtrips on first map load. 55 + 56 + proposal: add `density` to each user node returned by 57 + `/api/memory/graph`: 58 + 59 + ```json 60 + { 61 + "nodes": [ 62 + { 63 + "id": "...", 64 + "label": "@samuel.fm", 65 + "type": "user", 66 + "x": 0.31, "y": -0.42, 67 + "density": 12, // observation count (non-superseded) 68 + "has_summary": true // whether a summary kind exists 69 + }, 70 + ... 71 + ], 72 + "edges": [...] 73 + } 74 + ``` 75 + 76 + `density` is the same `len(active observations)` the user-view endpoint 77 + computes; you already have that pipeline. `has_summary` lets the UI 78 + visually mark which people phi has a synthesized impression of vs. 79 + people she only has scattered notes on. 80 + 81 + with that, the cockpit map can render proper density-based sizing 82 + without N parallel `/api/users/{handle}` calls on load. clicking still 83 + fetches `/api/users/{handle}` for the rich logbook view. 84 + 85 + ## priority 86 + 87 + `/api/skills` is the smaller change and unblocks the skills-as-map-kind 88 + work. the `/api/memory/graph` enrichment is nice-to-have — the map can 89 + ship without density (uniform sizing), then upgrade when the field 90 + lands.
+131
USER-VIEW.md
··· 1 + # user-view endpoint 2 + 3 + the cockpit's `mind` lens currently shows generic copy when you click on a 4 + handle node: 5 + 6 + > "they're in my memory. that could mean we've exchanged messages, or it could 7 + > mean i picked something up from a post nate liked — i can't tell from this 8 + > view alone." 9 + 10 + that's lackluster. it's also the wrong shape: the UI is supposed to be a 11 + window into phi's *actual* experience of someone, not a placeholder. but i 12 + won't fabricate a summary on the UI side via per-request LLM calls — that 13 + would be the UI doing semantic work phi itself doesn't have access to. the 14 + UI should only render state phi already maintains. 15 + 16 + the question is therefore: what does phi actually carry about a person, 17 + and can we surface it cleanly? 18 + 19 + ## what phi already has (no new state required) 20 + 21 + the user's tpuf namespace (`phi-users-{clean_handle}`) already holds 22 + everything we need: 23 + 24 + | kind | source | what it is | 25 + |---|---|---| 26 + | `observation` | `extract_and_store` after conversation; compact's likes-derived extraction; review pass | atomic fact phi has noted about this person | 27 + | `interaction` | `after_interaction` | verbatim turn — `user: ...\nbot: ...` | 28 + | `summary` | the `compact` flow (in my-prefect-server) | synthesized relationship summary, written periodically | 29 + 30 + phi reads all three live during conversation via 31 + `NamespaceMemory.build_user_context(handle, query_text)` — 32 + `namespace_memory.py:477`. so the contents are unambiguously phi-state. 33 + 34 + `is_stranger(handle)` (`namespace_memory.py:1021`) returns true when the 35 + namespace has fewer than 2 items. that's phi's own binary distinction 36 + between "someone i know" and "someone new" — also real state. 37 + 38 + ## proposed endpoint 39 + 40 + `GET /api/users/{handle}` 41 + 42 + ```json 43 + { 44 + "handle": "samuel.fm", 45 + "did": "did:plc:...", // null if not resolvable 46 + "is_stranger": false, // is_stranger() result — phi's own threshold 47 + "counts": { 48 + "observation": 8, // count where kind=observation, status!=superseded 49 + "interaction": 3, 50 + "summary": 1 51 + }, 52 + "first_seen": "2026-03-12T...", // earliest created_at across all kinds 53 + "last_seen": "2026-04-29T...", // latest created_at 54 + "summary": { // null if no summary kind exists 55 + "content": "...", // the synthesized text compact wrote 56 + "created_at": "..." 57 + }, 58 + "recent_observations": [ // top N by created_at desc, kind=observation 59 + { 60 + "content": "...", 61 + "tags": ["..."], 62 + "created_at": "...", 63 + "source_uris": ["at://..."] // already stored on observations 64 + } 65 + ] 66 + } 67 + ``` 68 + 69 + every field is a direct read of existing tpuf rows. no embedding, no LLM 70 + call, no fabrication. cap `recent_observations` at ~5 for response size. 71 + 72 + caching: tools registered at startup don't change, but the user view 73 + *does* — fresh each request, or with a very short TTL (60s). 74 + 75 + ## why this is the right shape 76 + 77 + - **histogram-as-counts**: gives the visual signal nate suggested 78 + ("how dense is phi's experience of this person") without inventing 79 + anything. zero observations + zero interactions = stranger; one summary 80 + + many interactions = a real relationship. 81 + - **summary front-and-center**: compact already writes synthesized 82 + relationship summaries to tpuf. phi reads them as her own impression 83 + during conversation. surfacing the same string in the UI is just 84 + showing what phi shows herself. 85 + - **recent observations**: the actual atomic facts. what phi *knows* 86 + about this person, written in phi's voice (because phi wrote them). 87 + - **source_uris**: each observation already records the at-uris that 88 + produced it. the UI can link back to the conversation/post that 89 + formed each observation — provenance for free. 90 + 91 + ## what NOT to do 92 + 93 + - don't run a haiku call per request to summarize on demand. the existing 94 + compact-written summary is the right artifact; if it's stale, the fix 95 + is to make compact run more often, not to pile UI-only synthesis on top. 96 + - don't fetch arbitrary external data (bsky author feed, pub-search, etc.) 97 + into this endpoint. that's "phi could go look this up" — phi has tools 98 + for that. the user-view endpoint is "what phi *currently carries*." 99 + 100 + ## implementation hints 101 + 102 + - in `bot/src/bot/main.py`, parallel to `/api/abilities`. reach the existing 103 + `NamespaceMemory` instance via `app.state.poller.handler.agent.memory` 104 + (it's already constructed in `PhiAgent.__init__`). 105 + - methods that already exist or are trivially expressible: `is_stranger`, 106 + `get_knowledge_count`, `get_relationship_summary`. `counts` per kind is 107 + one tpuf query per kind with `top_k=1` and reading the total — or three 108 + count queries. 109 + - handle the 404 case (namespace doesn't exist for this handle): 110 + return `{is_stranger: true, counts: {0,0,0}, summary: null, recent_observations: []}`. 111 + 112 + ## related thought (out of scope, raising for awareness) 113 + 114 + phi could call this exact endpoint as a tool herself — `who_is(handle)` — 115 + during conversation pre-flight. she'd get back the same view the UI 116 + gets. that would replace `_maybe_lookup_stranger`'s author-lookup-via-bsky 117 + with a memory-first equivalent. probably makes phi's first-touch behavior 118 + better but it's a behavior change, not a UI change. flagging only. 119 + 120 + ## what the UI does once this lands 121 + 122 + handle nodes in the `mind` atlas: on click, fetch 123 + `/api/users/{handle}`, render in the logbook drawer: 124 + 125 + - if `is_stranger`: small note saying so + the operator-likes context if 126 + it's also a discovery candidate. 127 + - otherwise: the histogram (3 small counts), the summary if present, 128 + the most recent observations as a chronological list, first_seen / 129 + last_seen as a single line. 130 + 131 + generic copy goes away.
+2 -2
docs/memory.md
··· 51 51 52 52 ## 3. public memory (cosmik / semble) 53 53 54 - **source**: phi's `cosmik-records` skill (writes via pdsx MCP) · **storage**: phi's PDS as `network.cosmik.*` records, indexed by [semble](https://semble.so) · **visibility**: public 54 + **source**: pdsx MCP (record CRUD), with the `cosmik-records` skill as wayfinding · **storage**: phi's PDS as `network.cosmik.*` records, indexed by [semble](https://semble.so) · **visibility**: public 55 55 56 56 three record types: 57 57 - `network.cosmik.card` (NOTE) — text notes 58 58 - `network.cosmik.card` (URL) — bookmarks with title/description 59 59 - `network.cosmik.connection` — typed semantic links between cards 60 60 61 - phi searches public memory via `search_network` (semble's semantic search). writes go through the `cosmik-records` skill — phi loads it on demand and uses `mcp__pdsx__create_record` to write records of the right shape. there's no per-record-type tool wrapper; the skill teaches the pattern instead. 61 + phi searches public memory via `search_network` (semble's semantic search). writes are direct `mcp__pdsx__create_record` calls — pdsx already supports any record under any lexicon. the `cosmik-records` skill is the per-record-type schema details and conventions, loaded on demand so phi writes the right shape. the skill is wayfinding, not the capability — pdsx is the capability. 62 62 63 63 ## 4. intent state (PDS) 64 64
+8
loq.toml
··· 22 22 [[rules]] 23 23 path = "src/bot/main.py" 24 24 max_lines = 851 25 + 26 + [[rules]] 27 + path = "web/src/lib/components/MindMap.svelte" 28 + max_lines = 600 29 + 30 + [[rules]] 31 + path = "web/src/lib/components/Logbook.svelte" 32 + max_lines = 650
+3 -1
skills/cosmik-records/SKILL.md
··· 1 1 --- 2 2 name: cosmik-records 3 - description: How to write to cosmik (your public knowledge graph on atproto via semble). Load this when you want to save a URL, write a public note, or create a typed connection between cards. The companion skill to pdsx-fundamentals — same mechanics, applied to the network.cosmik.* lexicons. 3 + description: Wayfinding for writing to cosmik (your public knowledge graph on atproto via semble). The capability — writing any network.cosmik.* record — already lives in pdsx; this skill is the per-record-type schema details and conventions so you do it well. Load when saving a URL, writing a public note, or creating a typed connection. 4 4 --- 5 5 6 6 cosmik is your public memory layer — bookmarks, notes, and typed connections, indexed by [semble](https://semble.so) and discoverable via `search_network`. records live on your PDS under `network.cosmik.*`. this skill covers the three you'll actually write. 7 + 8 + **this skill doesn't add a capability** — pdsx already lets you write any cosmik record. you could call `mcp__pdsx__create_record(collection="network.cosmik.card", record={...})` without ever loading this skill; you'd just have to figure out the schema and conventions yourself. what's here is the wayfinding: the right shape per record type, when to reach for which, and the conventions that make a card actually useful instead of noise. 7 9 8 10 read `pdsx-fundamentals` first if you haven't — this skill assumes you understand `mcp__pdsx__create_record` and the consent layer. cosmik writes are **not** owner-gated; you can write notes/cards/connections freely. they're public, but they're yours. 9 11
+2
skills/pdsx-fundamentals/SKILL.md
··· 73 73 - **leaflet records** (documents, comments): planned 74 74 - **phi self-records** (goals, observations, mention-consent): planned 75 75 76 + **important framing**: domain skills don't add new capabilities. pdsx already lets you write any record. skills are *lighthouses* — they tell you the schema, the conventions, the gotchas, so you do something well that you could already do crudely. the capability surface is wider than the skill surface, not narrower. 77 + 76 78 if no domain skill exists yet for what you want to do, you have everything you need above — find the lexicon, read its schema, construct a record, call `create_record`.
+1 -1
skills/publish-blog/SKILL.md
··· 1 1 --- 2 2 name: publish-blog 3 - description: Publish a long-form post on greengale.app. Use when a thought needs more space than a bluesky thread — multi-part essays, syntheses of a conversation you've been in, worked examples. For single observations use post; for a URL or note as public memory load the cosmik-records skill; for private notes to your future self use the note tool. 3 + description: Publish a long-form post on greengale.app. Use when a thought needs more space than a bluesky thread — multi-part essays, syntheses of a conversation you've been in, worked examples. For single observations use post; for a URL or note as public memory write a network.cosmik.card via pdsx (the cosmik-records skill has the per-record-type schema details); for private notes to your future self use the note tool. 4 4 --- 5 5 6 6 ## structure that's worked
+139
src/bot/main.py
··· 210 210 return JSONResponse({"error": str(e)}, status_code=500) 211 211 212 212 213 + _user_view_cache: dict[str, tuple[float, dict]] = {} 214 + _USER_VIEW_TTL = 60 # seconds 215 + 216 + 217 + @app.get("/api/users/{handle}") 218 + async def user_view(handle: str): 219 + """What phi currently carries about a person — pure read of state. 220 + 221 + Joins per-kind counts, the synthesized relationship summary (written by 222 + the compact flow in my-prefect-server), and the most recent atomic 223 + observations. No embedding, no LLM, no fabrication — every field is a 224 + direct read of rows in the user's tpuf namespace. 225 + 226 + See bot/USER-VIEW.md for the rationale and the UI consumer plan. 227 + """ 228 + now = time.monotonic() 229 + cached = _user_view_cache.get(handle) 230 + if cached and now < cached[0]: 231 + return JSONResponse(cached[1]) 232 + 233 + poller: NotificationPoller | None = getattr(app.state, "poller", None) 234 + if poller is None: 235 + return JSONResponse({"error": "agent not ready"}, status_code=503) 236 + memory = poller.handler.agent.memory 237 + if memory is None: 238 + return JSONResponse({"error": "memory not configured"}, status_code=503) 239 + 240 + # Resolve DID — best effort; not all handles resolve (deleted accounts, 241 + # bridged-from-mastodon, etc). Endpoint still returns useful state without it. 242 + did: str | None = None 243 + try: 244 + await bot_client.authenticate() 245 + profile = bot_client.client.app.bsky.actor.get_profile(params={"actor": handle}) 246 + did = profile.did 247 + except Exception: 248 + pass 249 + 250 + user_ns = memory.get_user_namespace(handle) 251 + 252 + # Per-kind counts. top_k=200 is enough margin for the UI signal — 253 + # most users have far fewer of each kind, and >200 of any one kind 254 + # renders the same way visually anyway. 255 + counts: dict[str, int] = {"observation": 0, "interaction": 0, "summary": 0} 256 + for kind in counts: 257 + active_filter = ( 258 + ["And", [["kind", "Eq", kind], ["status", "NotEq", "superseded"]]] 259 + if kind == "observation" 260 + else {"kind": ["Eq", kind]} 261 + ) 262 + try: 263 + resp = user_ns.query( 264 + rank_by=("created_at", "desc"), 265 + top_k=200, 266 + filters=active_filter, 267 + include_attributes=["kind"], 268 + ) 269 + counts[kind] = len(resp.rows or []) 270 + except Exception: 271 + pass # namespace may not exist yet; counts stay 0 272 + 273 + # first_seen / last_seen across all kinds. 274 + first_seen: str | None = None 275 + last_seen: str | None = None 276 + try: 277 + latest = user_ns.query( 278 + rank_by=("created_at", "desc"), 279 + top_k=1, 280 + include_attributes=["created_at"], 281 + ) 282 + if latest.rows: 283 + last_seen = getattr(latest.rows[0], "created_at", None) 284 + earliest = user_ns.query( 285 + rank_by=("created_at", "asc"), 286 + top_k=1, 287 + include_attributes=["created_at"], 288 + ) 289 + if earliest.rows: 290 + first_seen = getattr(earliest.rows[0], "created_at", None) 291 + except Exception: 292 + pass 293 + 294 + # Summary — text + its created_at (one query, since we want both). 295 + summary_obj: dict | None = None 296 + try: 297 + summary_resp = user_ns.query( 298 + rank_by=("created_at", "desc"), 299 + top_k=1, 300 + filters={"kind": ["Eq", "summary"]}, 301 + include_attributes=["content", "created_at"], 302 + ) 303 + if summary_resp.rows: 304 + row = summary_resp.rows[0] 305 + summary_obj = { 306 + "content": getattr(row, "content", ""), 307 + "created_at": getattr(row, "created_at", None), 308 + } 309 + except Exception: 310 + pass 311 + 312 + # Recent observations — content, tags, created_at, source_uris. 313 + recent_observations: list[dict] = [] 314 + try: 315 + obs_resp = user_ns.query( 316 + rank_by=("created_at", "desc"), 317 + top_k=5, 318 + filters=[ 319 + "And", 320 + [["kind", "Eq", "observation"], ["status", "NotEq", "superseded"]], 321 + ], 322 + include_attributes=["content", "tags", "created_at", "source_uris"], 323 + ) 324 + for row in obs_resp.rows or []: 325 + recent_observations.append( 326 + { 327 + "content": getattr(row, "content", ""), 328 + "tags": getattr(row, "tags", []) or [], 329 + "created_at": getattr(row, "created_at", None), 330 + "source_uris": getattr(row, "source_uris", []) or [], 331 + } 332 + ) 333 + except Exception: 334 + pass 335 + 336 + is_stranger = await memory.is_stranger(handle) 337 + 338 + payload = { 339 + "handle": handle, 340 + "did": did, 341 + "is_stranger": is_stranger, 342 + "counts": counts, 343 + "first_seen": first_seen, 344 + "last_seen": last_seen, 345 + "summary": summary_obj, 346 + "recent_observations": recent_observations, 347 + } 348 + _user_view_cache[handle] = (now + _USER_VIEW_TTL, payload) 349 + return JSONResponse(payload) 350 + 351 + 213 352 _discovery_cache_data: list | None = None 214 353 _discovery_cache_expires: float = 0.0 215 354 _DISCOVERY_CACHE_TTL = 60 # seconds
-55
web/src/lib/abilities.ts
··· 1 - /** 2 - * The capabilities phi currently has registered. 3 - * 4 - * Just the names. Hand-synced from `bot/src/bot/tools/*.py`. Once the bot 5 - * exposes `/api/abilities`, this is replaced by a fetch — meaningful 6 - * descriptions, real grouping, and the operator-only flag arrive then. 7 - * 8 - * No invented categories. No source-file leakage. The names are what they 9 - * are. 10 - * 11 - * Skills are a different surface (load-on-demand SKILL.md packs) and live 12 - * elsewhere — they don't belong here. 13 - */ 14 - 15 - export const CAPABILITIES: string[] = [ 16 - 'changelog', 17 - 'check_relays', 18 - 'check_services', 19 - 'check_urls', 20 - 'create_feed', 21 - 'delete_feed', 22 - 'drop_observation', 23 - 'follow_user', 24 - 'get_own_posts', 25 - 'get_trending', 26 - 'like_post', 27 - 'list_blog_posts', 28 - 'list_feeds', 29 - 'list_goals', 30 - 'manage_labels', 31 - 'manage_mentionable', 32 - 'note', 33 - 'observe', 34 - 'post', 35 - 'propose_goal_change', 36 - 'publish_blog_post', 37 - 'read_feed', 38 - 'read_timeline', 39 - 'recall', 40 - 'reply_to', 41 - 'repost_post', 42 - 'search_network', 43 - 'search_posts', 44 - 'web_search' 45 - ]; 46 - 47 - /** 48 - * Operator-gated capabilities — only nate (the bot's owner) can invoke 49 - * these. This is a real distinction in the source (the `_is_owner` check), 50 - * not invented. 51 - */ 52 - export const OPERATOR_ONLY: ReadonlySet<string> = new Set([ 53 - 'manage_labels', 54 - 'manage_mentionable' 55 - ]);
+12 -1
web/src/lib/api.ts
··· 12 12 Goal, 13 13 GraphData, 14 14 HealthInfo, 15 - Observation 15 + Observation, 16 + UserView 16 17 } from './types'; 17 18 18 19 export const PHI_DID = 'did:plc:65sucjiel52gefhcdcypynsr'; ··· 133 134 const res = await fetch('/api/abilities'); 134 135 if (!res.ok) throw new Error(`abilities: ${res.status}`); 135 136 return await res.json(); 137 + } 138 + 139 + export async function getUserView(handle: string): Promise<UserView | null> { 140 + try { 141 + const res = await fetch(`/api/users/${encodeURIComponent(handle)}`); 142 + if (!res.ok) return null; 143 + return await res.json(); 144 + } catch { 145 + return null; 146 + } 136 147 } 137 148 138 149 // --- bsky public API ---
-486
web/src/lib/components/Atlas.svelte
··· 1 - <script lang="ts"> 2 - import { onMount, onDestroy } from 'svelte'; 3 - import { hudReadout, logbook } from '$lib/state.svelte'; 4 - import type { AtlasPoint, LogbookEntry } from '$lib/types'; 5 - 6 - interface Props { 7 - points: AtlasPoint[]; 8 - // optional: edges to render between points (e.g. phi -> handles) 9 - edges?: { source: string; target: string }[]; 10 - // callback that turns a point into a logbook entry on click 11 - entryFor: (p: AtlasPoint) => LogbookEntry; 12 - } 13 - 14 - let { points, edges = [], entryFor }: Props = $props(); 15 - 16 - let canvas: HTMLCanvasElement; 17 - let dpr = 1; 18 - let W = 0, 19 - H = 0; 20 - 21 - // view state — pan + zoom in screen space 22 - const view = $state({ z: 1, px: 0, py: 0 }); 23 - const minZoom = 0.4; 24 - const maxZoom = 8; 25 - 26 - // kind → color mapping (resolved at render time from CSS vars) 27 - const KIND_COLORS: Record<string, string> = { 28 - phi: '--hud-hot', 29 - 'handle-engaged': '--text', 30 - 'handle-candidate': '--text-dim', 31 - observation: '--scan-mid', 32 - goal: '--warn' 33 - }; 34 - 35 - // avatar cache — patternId-style images, but using offscreen canvas blits 36 - const avatarCache = new Map<string, HTMLImageElement>(); 37 - const avatarLoading = new Set<string>(); 38 - const avatarFailed = new Set<string>(); 39 - 40 - function loadAvatar(p: AtlasPoint) { 41 - if (!p.avatar) return; 42 - const key = p.id; 43 - if (avatarCache.has(key) || avatarLoading.has(key) || avatarFailed.has(key)) return; 44 - avatarLoading.add(key); 45 - const img = new Image(); 46 - // Note: not setting crossOrigin — bsky CDN doesn't send Access-Control-Allow-Origin, 47 - // so anonymous requests are blocked. Leaving it unset lets the browser fetch and 48 - // drawImage anyway; we just can't getImageData() (which we don't need). 49 - img.onload = () => { 50 - avatarCache.set(key, img); 51 - avatarLoading.delete(key); 52 - scheduleFrame(); 53 - }; 54 - img.onerror = () => { 55 - avatarFailed.add(key); 56 - avatarLoading.delete(key); 57 - }; 58 - img.src = p.avatar; 59 - } 60 - 61 - let frameRequested = false; 62 - function scheduleFrame() { 63 - if (!frameRequested) { 64 - frameRequested = true; 65 - requestAnimationFrame(draw); 66 - } 67 - } 68 - 69 - // transform: normalized point.x/y in [-1,1] → screen pixels 70 - function worldToScreen(x: number, y: number): [number, number] { 71 - const scale = Math.min(W, H) * 0.42 * view.z; 72 - return [W / 2 + x * scale + view.px, H / 2 + y * scale + view.py]; 73 - } 74 - 75 - function resolveColor(name: string): string { 76 - const root = document.documentElement; 77 - return getComputedStyle(root).getPropertyValue(name).trim() || '#888'; 78 - } 79 - 80 - let hovered = $state<AtlasPoint | null>(null); 81 - 82 - function pointAt(mx: number, my: number): AtlasPoint | null { 83 - // reverse: pick the point closest to (mx,my) within radius 84 - let best: AtlasPoint | null = null; 85 - let bestDist = Infinity; 86 - for (const p of points) { 87 - const [sx, sy] = worldToScreen(p.x, p.y); 88 - const r = radiusFor(p); 89 - const dx = sx - mx; 90 - const dy = sy - my; 91 - const d2 = dx * dx + dy * dy; 92 - if (d2 < r * r * 1.6 && d2 < bestDist) { 93 - bestDist = d2; 94 - best = p; 95 - } 96 - } 97 - return best; 98 - } 99 - 100 - function radiusFor(p: AtlasPoint): number { 101 - // phi is largest, then engaged handles, then candidates, then concepts 102 - const base = 103 - p.kind === 'phi' ? 14 : p.kind === 'handle-engaged' ? 10 : p.kind === 'handle-candidate' ? 7 : 6; 104 - return base * Math.max(0.7, Math.min(1.6, view.z)); 105 - } 106 - 107 - function fadeIn(z: number, start: number, range: number) { 108 - return Math.max(0, Math.min(1, (z - start) / range)); 109 - } 110 - 111 - function draw() { 112 - frameRequested = false; 113 - if (!canvas) return; 114 - const ctx = canvas.getContext('2d'); 115 - if (!ctx) return; 116 - 117 - ctx.save(); 118 - ctx.scale(dpr, dpr); 119 - ctx.clearRect(0, 0, W, H); 120 - 121 - // faint grid — subtle scan-visor reference 122 - drawGrid(ctx); 123 - 124 - // edges 125 - ctx.lineWidth = 1; 126 - ctx.strokeStyle = resolveColor('--line-dim'); 127 - const idIndex = new Map(points.map((p) => [p.id, p])); 128 - for (const e of edges) { 129 - const a = idIndex.get(e.source); 130 - const b = idIndex.get(e.target); 131 - if (!a || !b) continue; 132 - const [ax, ay] = worldToScreen(a.x, a.y); 133 - const [bx, by] = worldToScreen(b.x, b.y); 134 - ctx.beginPath(); 135 - ctx.moveTo(ax, ay); 136 - ctx.lineTo(bx, by); 137 - ctx.stroke(); 138 - } 139 - 140 - // points 141 - for (const p of points) { 142 - const [sx, sy] = worldToScreen(p.x, p.y); 143 - const r = radiusFor(p); 144 - const colorVar = KIND_COLORS[p.kind] ?? '--text-dim'; 145 - const color = resolveColor(colorVar); 146 - 147 - const img = avatarCache.get(p.id); 148 - if (img && p.kind !== 'handle-candidate') { 149 - // engaged handles get avatar fill 150 - ctx.save(); 151 - ctx.beginPath(); 152 - ctx.arc(sx, sy, r, 0, Math.PI * 2); 153 - ctx.clip(); 154 - ctx.drawImage(img, sx - r, sy - r, r * 2, r * 2); 155 - ctx.restore(); 156 - ctx.lineWidth = 1.5; 157 - ctx.strokeStyle = color; 158 - ctx.beginPath(); 159 - ctx.arc(sx, sy, r, 0, Math.PI * 2); 160 - ctx.stroke(); 161 - } else if (p.kind === 'handle-candidate') { 162 - // candidates: dim outline only, ghosted 163 - ctx.lineWidth = 1; 164 - ctx.strokeStyle = color; 165 - ctx.setLineDash([2, 2]); 166 - ctx.beginPath(); 167 - ctx.arc(sx, sy, r, 0, Math.PI * 2); 168 - ctx.stroke(); 169 - ctx.setLineDash([]); 170 - } else if (p.kind === 'phi') { 171 - // phi: filled ring with glow 172 - ctx.beginPath(); 173 - ctx.arc(sx, sy, r * 1.6, 0, Math.PI * 2); 174 - const grd = ctx.createRadialGradient(sx, sy, r * 0.4, sx, sy, r * 1.6); 175 - grd.addColorStop(0, color); 176 - grd.addColorStop(1, 'rgba(184,107,58,0)'); 177 - ctx.fillStyle = grd; 178 - ctx.fill(); 179 - ctx.beginPath(); 180 - ctx.arc(sx, sy, r, 0, Math.PI * 2); 181 - ctx.fillStyle = color; 182 - ctx.fill(); 183 - } else { 184 - // observations + goals: small filled dots 185 - ctx.beginPath(); 186 - ctx.arc(sx, sy, r, 0, Math.PI * 2); 187 - ctx.fillStyle = color; 188 - ctx.globalAlpha = 0.85; 189 - ctx.fill(); 190 - ctx.globalAlpha = 1; 191 - } 192 - } 193 - 194 - // labels — fade in at zoom >= 0.9 195 - const labelAlpha = fadeIn(view.z, 0.9, 0.5); 196 - if (labelAlpha > 0.01) { 197 - ctx.font = '10px "JetBrains Mono", monospace'; 198 - ctx.textAlign = 'center'; 199 - ctx.textBaseline = 'top'; 200 - for (const p of points) { 201 - const [sx, sy] = worldToScreen(p.x, p.y); 202 - const r = radiusFor(p); 203 - const label = labelFor(p); 204 - if (!label) continue; 205 - ctx.fillStyle = resolveColor('--text-mid'); 206 - ctx.globalAlpha = labelAlpha * (p.kind === 'phi' ? 1 : p.kind === 'handle-engaged' ? 0.9 : 0.5); 207 - ctx.fillText(label, sx, sy + r + 6); 208 - } 209 - ctx.globalAlpha = 1; 210 - } 211 - 212 - // reticle on hovered point 213 - if (hovered) { 214 - const [hx, hy] = worldToScreen(hovered.x, hovered.y); 215 - drawReticle(ctx, hx, hy, radiusFor(hovered) + 4); 216 - } 217 - 218 - ctx.restore(); 219 - } 220 - 221 - function labelFor(p: AtlasPoint): string { 222 - if (p.kind === 'phi') return 'phi'; 223 - if (p.kind === 'handle-engaged' || p.kind === 'handle-candidate') return p.label; 224 - // observations + goals: don't label by default — too noisy. Only when zoomed in further. 225 - if (view.z >= 1.6) return p.label.length > 32 ? p.label.slice(0, 32) + '…' : p.label; 226 - return ''; 227 - } 228 - 229 - function drawReticle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { 230 - ctx.lineWidth = 1.2; 231 - ctx.strokeStyle = resolveColor('--hud-hot'); 232 - const arm = 6; 233 - // four corners 234 - const corners = [ 235 - [-1, -1], 236 - [1, -1], 237 - [-1, 1], 238 - [1, 1] 239 - ]; 240 - for (const [sx, sy] of corners) { 241 - const x = cx + sx * r; 242 - const y = cy + sy * r; 243 - ctx.beginPath(); 244 - ctx.moveTo(x, y - sy * arm); 245 - ctx.lineTo(x, y); 246 - ctx.lineTo(x - sx * arm, y); 247 - ctx.stroke(); 248 - } 249 - } 250 - 251 - function drawGrid(ctx: CanvasRenderingContext2D) { 252 - // concentric circles around center, scaled to zoom — scaffolding only 253 - ctx.strokeStyle = resolveColor('--grid'); 254 - ctx.lineWidth = 1; 255 - const cx = W / 2 + view.px; 256 - const cy = H / 2 + view.py; 257 - const baseR = Math.min(W, H) * 0.18 * view.z; 258 - for (let i = 1; i <= 4; i++) { 259 - ctx.beginPath(); 260 - ctx.arc(cx, cy, baseR * i, 0, Math.PI * 2); 261 - ctx.stroke(); 262 - } 263 - // crosshairs 264 - ctx.beginPath(); 265 - ctx.moveTo(cx - W, cy); 266 - ctx.lineTo(cx + W, cy); 267 - ctx.moveTo(cx, cy - H); 268 - ctx.lineTo(cx, cy + H); 269 - ctx.stroke(); 270 - } 271 - 272 - // --- input --- 273 - // Pointer events: support mouse (hover + wheel + click) and touch 274 - // (single-finger pan, two-finger pinch zoom, tap to open logbook). 275 - // Hover/readout is mouse-only — touch users get tap-opens-logbook. 276 - 277 - const activePointers = new Map<number, { x: number; y: number; type: string }>(); 278 - let dragging = false; 279 - let lastX = 0, 280 - lastY = 0; 281 - let panStartX = 0, 282 - panStartY = 0; 283 - let pinchStartDist = 0; 284 - let pinchStartZoom = 1; 285 - let pinchCx = 0, 286 - pinchCy = 0; 287 - let pinching = false; 288 - const TAP_THRESHOLD = 8; // px movement allowed for a tap 289 - 290 - function applyZoomAt(mx: number, my: number, newZ: number) { 291 - newZ = Math.max(minZoom, Math.min(maxZoom, newZ)); 292 - const ratio = newZ / view.z; 293 - view.px = mx - (mx - W / 2 - view.px) * ratio - W / 2; 294 - view.py = my - (my - H / 2 - view.py) * ratio - H / 2; 295 - view.z = newZ; 296 - } 297 - 298 - function onPointerDown(e: PointerEvent) { 299 - canvas.setPointerCapture(e.pointerId); 300 - activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType }); 301 - 302 - if (activePointers.size === 1) { 303 - dragging = true; 304 - pinching = false; 305 - lastX = e.clientX; 306 - lastY = e.clientY; 307 - panStartX = e.clientX; 308 - panStartY = e.clientY; 309 - } else if (activePointers.size === 2) { 310 - pinching = true; 311 - dragging = false; 312 - const [a, b] = [...activePointers.values()]; 313 - pinchStartDist = Math.hypot(b.x - a.x, b.y - a.y); 314 - pinchStartZoom = view.z; 315 - const rect = canvas.getBoundingClientRect(); 316 - pinchCx = (a.x + b.x) / 2 - rect.left; 317 - pinchCy = (a.y + b.y) / 2 - rect.top; 318 - } 319 - } 320 - 321 - function onPointerMove(e: PointerEvent) { 322 - const rect = canvas.getBoundingClientRect(); 323 - const mx = e.clientX - rect.left; 324 - const my = e.clientY - rect.top; 325 - 326 - if (activePointers.has(e.pointerId)) { 327 - activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType }); 328 - } 329 - 330 - if (pinching && activePointers.size >= 2) { 331 - const [a, b] = [...activePointers.values()]; 332 - const dist = Math.hypot(b.x - a.x, b.y - a.y); 333 - if (pinchStartDist > 0) { 334 - applyZoomAt(pinchCx, pinchCy, pinchStartZoom * (dist / pinchStartDist)); 335 - scheduleFrame(); 336 - } 337 - return; 338 - } 339 - 340 - if (dragging) { 341 - view.px += e.clientX - lastX; 342 - view.py += e.clientY - lastY; 343 - lastX = e.clientX; 344 - lastY = e.clientY; 345 - scheduleFrame(); 346 - return; 347 - } 348 - 349 - // hover (mouse only — touch fingers without buttons aren't here) 350 - if (e.pointerType === 'mouse') { 351 - const p = pointAt(mx, my); 352 - if (p !== hovered) { 353 - hovered = p; 354 - hudReadout.set(p ? readoutFor(p) : ''); 355 - canvas.style.cursor = p ? 'pointer' : 'grab'; 356 - scheduleFrame(); 357 - } 358 - } 359 - } 360 - 361 - function onPointerUp(e: PointerEvent) { 362 - const wasTap = 363 - Math.abs(e.clientX - panStartX) < TAP_THRESHOLD && 364 - Math.abs(e.clientY - panStartY) < TAP_THRESHOLD && 365 - !pinching; 366 - 367 - activePointers.delete(e.pointerId); 368 - try { 369 - canvas.releasePointerCapture(e.pointerId); 370 - } catch { 371 - /* may not have capture */ 372 - } 373 - 374 - if (activePointers.size === 0) { 375 - dragging = false; 376 - pinching = false; 377 - // touch tap → open logbook (mouse uses onClick separately) 378 - if (wasTap && e.pointerType !== 'mouse') { 379 - const rect = canvas.getBoundingClientRect(); 380 - const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 381 - if (p) logbook.set(entryFor(p)); 382 - } 383 - } else if (activePointers.size === 1) { 384 - pinching = false; 385 - const [first] = [...activePointers.values()]; 386 - lastX = first.x; 387 - lastY = first.y; 388 - panStartX = first.x; 389 - panStartY = first.y; 390 - dragging = true; 391 - } 392 - } 393 - 394 - function onClick(e: MouseEvent) { 395 - // mouse-only — touch handled in onPointerUp 396 - if ((e as PointerEvent).pointerType && (e as PointerEvent).pointerType !== 'mouse') return; 397 - const rect = canvas.getBoundingClientRect(); 398 - const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 399 - if (p) logbook.set(entryFor(p)); 400 - } 401 - 402 - function onWheel(e: WheelEvent) { 403 - e.preventDefault(); 404 - const rect = canvas.getBoundingClientRect(); 405 - applyZoomAt(e.clientX - rect.left, e.clientY - rect.top, view.z * Math.exp(-e.deltaY * 0.0015)); 406 - scheduleFrame(); 407 - } 408 - 409 - function readoutFor(p: AtlasPoint): string { 410 - const kindLabels: Record<string, string> = { 411 - phi: 'self', 412 - 'handle-engaged': 'in memory', 413 - 'handle-candidate': 'on radar', 414 - observation: 'attention', 415 - goal: 'goal' 416 - }; 417 - return `${kindLabels[p.kind]} · ${p.label}`; 418 - } 419 - 420 - // --- resize --- 421 - 422 - let ro: ResizeObserver | null = null; 423 - function resize() { 424 - if (!canvas) return; 425 - const rect = canvas.parentElement!.getBoundingClientRect(); 426 - W = rect.width; 427 - H = rect.height; 428 - dpr = window.devicePixelRatio || 1; 429 - canvas.width = W * dpr; 430 - canvas.height = H * dpr; 431 - canvas.style.width = `${W}px`; 432 - canvas.style.height = `${H}px`; 433 - scheduleFrame(); 434 - } 435 - 436 - onMount(() => { 437 - resize(); 438 - ro = new ResizeObserver(resize); 439 - if (canvas?.parentElement) ro.observe(canvas.parentElement); 440 - // preload avatars 441 - for (const p of points) loadAvatar(p); 442 - scheduleFrame(); 443 - }); 444 - 445 - onDestroy(() => { 446 - ro?.disconnect(); 447 - hudReadout.set(''); 448 - }); 449 - 450 - // reload avatars when points change 451 - $effect(() => { 452 - for (const p of points) loadAvatar(p); 453 - scheduleFrame(); 454 - }); 455 - </script> 456 - 457 - <div class="atlas-host"> 458 - <canvas 459 - bind:this={canvas} 460 - onpointerdown={onPointerDown} 461 - onpointermove={onPointerMove} 462 - onpointerup={onPointerUp} 463 - onpointercancel={onPointerUp} 464 - onpointerleave={() => { 465 - hovered = null; 466 - hudReadout.set(''); 467 - canvas.style.cursor = 'grab'; 468 - scheduleFrame(); 469 - }} 470 - onclick={onClick} 471 - onwheel={onWheel} 472 - ></canvas> 473 - </div> 474 - 475 - <style> 476 - .atlas-host { 477 - position: absolute; 478 - inset: 0; 479 - } 480 - 481 - canvas { 482 - display: block; 483 - cursor: grab; 484 - touch-action: none; 485 - } 486 - </style>
+235 -10
web/src/lib/components/Logbook.svelte
··· 1 1 <script lang="ts"> 2 2 import { logbook } from '$lib/state.svelte'; 3 3 import { relativeWhen } from '$lib/time'; 4 - import { PHI_HANDLE, PHI_DID } from '$lib/api'; 4 + import { PHI_HANDLE, PHI_DID, getUserView } from '$lib/api'; 5 5 import ViewIn from './ViewIn.svelte'; 6 - import type { Goal, Observation, ActivityItem, BlogDoc, DiscoveryEntry } from '$lib/types'; 6 + import type { 7 + Goal, 8 + Observation, 9 + ActivityItem, 10 + BlogDoc, 11 + DiscoveryEntry, 12 + UserView 13 + } from '$lib/types'; 7 14 8 15 // Resolve an at-uri or a record reference into the bits ViewIn needs. 9 16 function rkeyFromUri(uri: string): string { ··· 35 42 return () => window.removeEventListener('keydown', handleKey); 36 43 } 37 44 }); 45 + 46 + // User-view fetch: when the entry is a 'handle' or 'discovery', go pull 47 + // /api/users/{handle}. This is the rich state phi carries about a person — 48 + // histogram, summary, recent observations. 49 + // 50 + // `lastFetchedHandle` is a plain `let` (not $state) so the effect doesn't 51 + // track it — otherwise Svelte detects the read+write of the same piece of 52 + // state and throws effect_update_depth_exceeded. 53 + let userView = $state<UserView | null>(null); 54 + let userViewLoading = $state(false); 55 + let lastFetchedHandle: string | null = null; 56 + 57 + $effect(() => { 58 + if (!entry) { 59 + userView = null; 60 + lastFetchedHandle = null; 61 + return; 62 + } 63 + const handle = 64 + entry.kind === 'handle' 65 + ? entry.handle 66 + : entry.kind === 'discovery' 67 + ? entry.entry.handle 68 + : null; 69 + if (!handle) { 70 + userView = null; 71 + lastFetchedHandle = null; 72 + return; 73 + } 74 + if (handle === lastFetchedHandle) return; 75 + lastFetchedHandle = handle; 76 + userView = null; 77 + userViewLoading = true; 78 + getUserView(handle).then((uv) => { 79 + if (handle === lastFetchedHandle) { 80 + userView = uv; 81 + userViewLoading = false; 82 + } 83 + }); 84 + }); 38 85 </script> 39 86 40 87 {#if entry} ··· 62 109 payload: unknown; 63 110 }} 64 111 <h1 class="mono">@{handleEntry.handle}</h1> 65 - <p class="muted"> 66 - {#if handleEntry.engaged}they're in my memory. that could mean we've exchanged messages, or it could mean i picked something up from a post nate liked — i can't tell from this view alone.{:else}not in my memory yet. nate liked something they wrote, and that put them on my radar.{/if} 67 - </p> 112 + 113 + {#if userViewLoading} 114 + <p class="muted">recalling…</p> 115 + {:else if userView} 116 + <!-- top-line: phi's own framing of how she knows this person --> 117 + <p class="muted"> 118 + {#if userView.is_stranger && userView.counts.observation === 0 && userView.counts.interaction === 0} 119 + i don't carry anything about them yet. 120 + {:else if userView.is_stranger} 121 + a thin sketch — not enough yet to feel like i know them. 122 + {:else} 123 + they're someone i carry. 124 + {/if} 125 + </p> 126 + 127 + <!-- histogram: counts per kind --> 128 + <div class="hist"> 129 + <div class="hist-cell"> 130 + <div class="hist-num mono">{userView.counts.observation}</div> 131 + <div class="hist-lbl chrome">observation{userView.counts.observation === 1 ? '' : 's'}</div> 132 + </div> 133 + <div class="hist-cell"> 134 + <div class="hist-num mono">{userView.counts.interaction}</div> 135 + <div class="hist-lbl chrome">exchange{userView.counts.interaction === 1 ? '' : 's'}</div> 136 + </div> 137 + <div class="hist-cell"> 138 + <div class="hist-num mono">{userView.counts.summary}</div> 139 + <div class="hist-lbl chrome">impression{userView.counts.summary === 1 ? '' : 's'}</div> 140 + </div> 141 + </div> 142 + 143 + {#if userView.first_seen} 144 + <div class="span chrome faint"> 145 + first noted {relativeWhen(userView.first_seen)} 146 + {#if userView.last_seen && userView.last_seen !== userView.first_seen} 147 + · last touched {relativeWhen(userView.last_seen)} 148 + {/if} 149 + </div> 150 + {/if} 151 + 152 + {#if userView.summary} 153 + <div class="block"> 154 + <div class="block-label chrome">my impression</div> 155 + <div class="content">{userView.summary.content}</div> 156 + </div> 157 + {/if} 158 + 159 + {#if userView.recent_observations.length > 0} 160 + <div class="block"> 161 + <div class="block-label chrome">recent notes</div> 162 + <ul class="obs-list"> 163 + {#each userView.recent_observations as obs (obs.created_at ?? obs.content)} 164 + <li class="obs"> 165 + <div class="obs-text">{obs.content}</div> 166 + <div class="obs-meta faint"> 167 + {#if obs.tags.length > 0} 168 + <span class="tags mono">{obs.tags.slice(0, 3).join(' · ')}</span> 169 + {/if} 170 + {#if obs.created_at} 171 + <span class="when">{relativeWhen(obs.created_at)}</span> 172 + {/if} 173 + </div> 174 + </li> 175 + {/each} 176 + </ul> 177 + </div> 178 + {/if} 179 + {:else} 180 + <p class="muted">memory unreachable.</p> 181 + {/if} 182 + 68 183 <div class="actions"> 69 184 <ViewIn kind="profile" handle={handleEntry.handle} did={handleEntry.did} /> 70 185 </div> ··· 173 288 {:else if entry.kind === 'discovery'} 174 289 {@const disc = entry as { kind: 'discovery'; entry: DiscoveryEntry }} 175 290 <h1 class="mono">@{disc.entry.handle}</h1> 176 - <p class="muted"> 177 - not in my memory yet. nate liked {disc.entry.likes_in_window} thing{disc.entry.likes_in_window === 1 178 - ? '' 179 - : 's'} they wrote, most recently {relativeWhen(disc.entry.last_liked_at)}. 180 - </p> 291 + {#if userView && !userView.is_stranger} 292 + <p class="muted">someone i already carry, also surfacing on my radar:</p> 293 + <div class="hist"> 294 + <div class="hist-cell"> 295 + <div class="hist-num mono">{userView.counts.observation}</div> 296 + <div class="hist-lbl chrome">obs</div> 297 + </div> 298 + <div class="hist-cell"> 299 + <div class="hist-num mono">{userView.counts.interaction}</div> 300 + <div class="hist-lbl chrome">exch</div> 301 + </div> 302 + <div class="hist-cell"> 303 + <div class="hist-num mono">{disc.entry.likes_in_window}</div> 304 + <div class="hist-lbl chrome">likes</div> 305 + </div> 306 + </div> 307 + {:else} 308 + <p class="muted"> 309 + not in my memory yet. nate liked {disc.entry.likes_in_window} thing{disc.entry 310 + .likes_in_window === 1 311 + ? '' 312 + : 's'} they wrote, most recently {relativeWhen(disc.entry.last_liked_at)}. 313 + </p> 314 + {/if} 181 315 {#if disc.entry.sample_posts.length} 182 316 <div class="block"> 183 317 <div class="block-label chrome">what nate liked</div> ··· 335 469 flex-wrap: wrap; 336 470 align-items: center; 337 471 margin-top: 8px; 472 + } 473 + 474 + /* user-view histogram */ 475 + .hist { 476 + display: flex; 477 + gap: 0; 478 + margin: 6px 0 4px; 479 + border: 1px solid var(--line-mid); 480 + clip-path: polygon( 481 + 6px 0, 482 + 100% 0, 483 + 100% calc(100% - 6px), 484 + calc(100% - 6px) 100%, 485 + 0 100%, 486 + 0 6px 487 + ); 488 + } 489 + 490 + .hist-cell { 491 + flex: 1; 492 + display: flex; 493 + flex-direction: column; 494 + align-items: center; 495 + justify-content: center; 496 + padding: 10px 8px 8px; 497 + gap: 2px; 498 + background: rgba(184, 107, 58, 0.04); 499 + border-right: 1px solid var(--line-dim); 500 + } 501 + 502 + .hist-cell:last-child { 503 + border-right: none; 504 + } 505 + 506 + .hist-num { 507 + font-size: 20px; 508 + color: var(--scan-hot); 509 + line-height: 1; 510 + } 511 + 512 + .hist-lbl { 513 + font-size: 8px; 514 + color: var(--text-dim); 515 + letter-spacing: 0.18em; 516 + } 517 + 518 + .span { 519 + font-size: 10px; 520 + letter-spacing: 0.1em; 521 + margin: 0 0 2px; 522 + } 523 + 524 + .obs-list { 525 + list-style: none; 526 + padding: 0; 527 + margin: 0; 528 + display: flex; 529 + flex-direction: column; 530 + gap: 8px; 531 + } 532 + 533 + .obs { 534 + padding: 6px 0; 535 + border-bottom: 1px solid var(--line-dim); 536 + } 537 + 538 + .obs:last-child { 539 + border-bottom: none; 540 + } 541 + 542 + .obs-text { 543 + font-size: 12px; 544 + line-height: 1.5; 545 + color: var(--text); 546 + margin-bottom: 4px; 547 + white-space: pre-wrap; 548 + } 549 + 550 + .obs-meta { 551 + display: flex; 552 + gap: 8px; 553 + font-size: 10px; 554 + } 555 + 556 + .tags { 557 + color: var(--scan-mid); 558 + font-size: 9px; 559 + } 560 + 561 + .when { 562 + color: var(--text-dim); 338 563 } 339 564 340 565 .extlink {
+584
web/src/lib/components/MindMap.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { hudReadout, logbook } from '$lib/state.svelte'; 4 + import type { 5 + AtlasPoint, 6 + LogbookEntry, 7 + Goal, 8 + Observation, 9 + DiscoveryEntry, 10 + GraphNode 11 + } from '$lib/types'; 12 + import { PHI_HANDLE } from '$lib/api'; 13 + import { relativeWhen } from '$lib/time'; 14 + 15 + /** 16 + * Phi-centric concentric rings: 17 + * 0.18 — anchors (goals) 18 + * 0.32 — attention (active observations) 19 + * 0.55 — memory (people phi has notes about) 20 + * 0.85 — horizon (people on phi's radar via discovery) 21 + * 22 + * Phi is fixed at center. Each ring is drawn as a faint orange circle 23 + * with a chrome tick + label at 12 o'clock so the structure is legible. 24 + * Items snap to their ring radius. Angular position has meaning per ring 25 + * (see place()). 26 + */ 27 + 28 + interface Props { 29 + goals: Goal[]; 30 + observations: Observation[]; 31 + known: GraphNode[]; // user-type nodes from /api/memory/graph (we use only their (x,y) to derive angle) 32 + candidates: DiscoveryEntry[]; 33 + avatars: Record<string, string>; 34 + } 35 + 36 + let { goals, observations, known, candidates, avatars }: Props = $props(); 37 + 38 + let canvas: HTMLCanvasElement; 39 + let dpr = 1; 40 + let W = 0, 41 + H = 0; 42 + 43 + const RING = { 44 + anchors: 0.18, 45 + attention: 0.32, 46 + memory: 0.55, 47 + horizon: 0.85 48 + } as const; 49 + 50 + const RING_LABELS: { r: number; label: string }[] = [ 51 + { r: RING.anchors, label: 'anchors' }, 52 + { r: RING.attention, label: 'attention' }, 53 + { r: RING.memory, label: 'in memory' }, 54 + { r: RING.horizon, label: 'on the horizon' } 55 + ]; 56 + 57 + // hover/select state 58 + let hovered = $state<AtlasPoint | null>(null); 59 + 60 + // avatar image cache 61 + const imageCache = new Map<string, HTMLImageElement>(); 62 + const imageLoading = new Set<string>(); 63 + const imageFailed = new Set<string>(); 64 + 65 + function loadImage(url: string) { 66 + if (imageCache.has(url) || imageLoading.has(url) || imageFailed.has(url)) return; 67 + imageLoading.add(url); 68 + const img = new Image(); 69 + img.onload = () => { 70 + imageCache.set(url, img); 71 + imageLoading.delete(url); 72 + scheduleFrame(); 73 + }; 74 + img.onerror = () => { 75 + imageFailed.add(url); 76 + imageLoading.delete(url); 77 + }; 78 + img.src = url; 79 + } 80 + 81 + // Compute placed points each time data changes. 82 + let points = $state<AtlasPoint[]>([]); 83 + 84 + function place(): AtlasPoint[] { 85 + const out: AtlasPoint[] = []; 86 + 87 + // phi at center 88 + out.push({ 89 + id: 'phi', 90 + kind: 'phi', 91 + label: 'phi', 92 + x: 0, 93 + y: 0, 94 + avatar: avatars[PHI_HANDLE] ?? null, 95 + payload: {} 96 + }); 97 + 98 + // goals — sort by created_at ascending so older anchors are stable; even angular distribution 99 + const sortedGoals = [...goals].sort((a, b) => a.created_at.localeCompare(b.created_at)); 100 + for (let i = 0; i < sortedGoals.length; i++) { 101 + const g = sortedGoals[i]; 102 + const angle = (-Math.PI / 2) + (i / Math.max(sortedGoals.length, 1)) * Math.PI * 2; 103 + out.push({ 104 + id: `goal-${g.rkey}`, 105 + kind: 'goal', 106 + label: g.title, 107 + x: Math.cos(angle) * RING.anchors, 108 + y: Math.sin(angle) * RING.anchors, 109 + payload: g 110 + }); 111 + } 112 + 113 + // observations — same idea, by rkey order (TID-sortable, so chronological) 114 + const sortedObs = [...observations].sort((a, b) => a.rkey.localeCompare(b.rkey)); 115 + for (let i = 0; i < sortedObs.length; i++) { 116 + const o = sortedObs[i]; 117 + const angle = (-Math.PI / 2) + (i / Math.max(sortedObs.length, 1)) * Math.PI * 2; 118 + out.push({ 119 + id: `obs-${o.rkey}`, 120 + kind: 'observation', 121 + label: o.content, 122 + x: Math.cos(angle) * RING.attention, 123 + y: Math.sin(angle) * RING.attention, 124 + payload: o 125 + }); 126 + } 127 + 128 + // known people — preserve angular hint from graph embedding (x,y → angle), snap to ring radius 129 + const knownEntries = known.filter((n) => n.type === 'user'); 130 + for (const n of knownEntries) { 131 + const handle = n.label.replace(/^@/, ''); 132 + const angle = n.x != null && n.y != null && (n.x !== 0 || n.y !== 0) 133 + ? Math.atan2(n.y, n.x) 134 + : hashAngle(handle); 135 + out.push({ 136 + id: n.id, 137 + kind: 'handle-engaged', 138 + label: n.label, 139 + x: Math.cos(angle) * RING.memory, 140 + y: Math.sin(angle) * RING.memory, 141 + avatar: avatars[handle] ?? null, 142 + payload: { handle } 143 + }); 144 + } 145 + 146 + // candidates — angular position by recency (most recent at top, clockwise) 147 + const sortedCands = [...candidates].sort( 148 + (a, b) => b.last_liked_at.localeCompare(a.last_liked_at) 149 + ); 150 + const knownHandles = new Set( 151 + knownEntries.map((n) => n.label.replace(/^@/, '')) 152 + ); 153 + const filteredCands = sortedCands.filter((c) => !knownHandles.has(c.handle)); 154 + for (let i = 0; i < filteredCands.length; i++) { 155 + const c = filteredCands[i]; 156 + const angle = (-Math.PI / 2) + (i / Math.max(filteredCands.length, 1)) * Math.PI * 2; 157 + out.push({ 158 + id: `cand-${c.did}`, 159 + kind: 'handle-candidate', 160 + label: `@${c.handle}`, 161 + x: Math.cos(angle) * RING.horizon, 162 + y: Math.sin(angle) * RING.horizon, 163 + avatar: avatars[c.handle] ?? null, 164 + payload: { handle: c.handle, did: c.did, entry: c } 165 + }); 166 + } 167 + 168 + return out; 169 + } 170 + 171 + // Stable hash for angle when graph embedding has no info. 172 + function hashAngle(s: string): number { 173 + let h = 2166136261; 174 + for (let i = 0; i < s.length; i++) { 175 + h ^= s.charCodeAt(i); 176 + h = Math.imul(h, 16777619); 177 + } 178 + return ((h >>> 0) % 10000) / 10000 * Math.PI * 2; 179 + } 180 + 181 + $effect(() => { 182 + // reactive on inputs — read into a local first so we don't re-read 183 + // `points` (which we just wrote) and trigger Svelte's depth check. 184 + void goals; 185 + void observations; 186 + void known; 187 + void candidates; 188 + void avatars; 189 + const placed = place(); 190 + for (const p of placed) if (p.avatar) loadImage(p.avatar); 191 + points = placed; 192 + scheduleFrame(); 193 + }); 194 + 195 + // ---- coordinate / draw ---- 196 + 197 + function unit(): number { 198 + return Math.min(W, H) * 0.42; 199 + } 200 + 201 + function worldToScreen(x: number, y: number): [number, number] { 202 + const u = unit(); 203 + return [W / 2 + x * u, H / 2 + y * u]; 204 + } 205 + 206 + function resolve(name: string): string { 207 + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; 208 + } 209 + 210 + function radiusFor(p: AtlasPoint): number { 211 + if (p.kind === 'phi') return 26; 212 + if (p.kind === 'handle-engaged') return 11; 213 + if (p.kind === 'handle-candidate') return 5; 214 + if (p.kind === 'goal') return 7; 215 + if (p.kind === 'observation') return 5; 216 + return 5; 217 + } 218 + 219 + let frameRequested = false; 220 + function scheduleFrame() { 221 + if (!frameRequested) { 222 + frameRequested = true; 223 + requestAnimationFrame(draw); 224 + } 225 + } 226 + 227 + function draw() { 228 + frameRequested = false; 229 + if (!canvas) return; 230 + const ctx = canvas.getContext('2d'); 231 + if (!ctx) return; 232 + ctx.save(); 233 + ctx.scale(dpr, dpr); 234 + ctx.clearRect(0, 0, W, H); 235 + 236 + drawRings(ctx); 237 + drawAnchorSpokes(ctx); 238 + drawPoints(ctx); 239 + drawRingLabels(ctx); 240 + if (hovered) drawReticle(ctx, hovered); 241 + 242 + ctx.restore(); 243 + } 244 + 245 + function drawRings(ctx: CanvasRenderingContext2D) { 246 + const u = unit(); 247 + const cx = W / 2, 248 + cy = H / 2; 249 + ctx.strokeStyle = resolve('--grid'); 250 + ctx.lineWidth = 1; 251 + for (const { r } of RING_LABELS) { 252 + ctx.beginPath(); 253 + ctx.arc(cx, cy, r * u, 0, Math.PI * 2); 254 + ctx.stroke(); 255 + } 256 + // faint axes 257 + ctx.beginPath(); 258 + ctx.moveTo(cx - W, cy); 259 + ctx.lineTo(cx + W, cy); 260 + ctx.moveTo(cx, cy - H); 261 + ctx.lineTo(cx, cy + H); 262 + ctx.stroke(); 263 + } 264 + 265 + function drawRingLabels(ctx: CanvasRenderingContext2D) { 266 + const u = unit(); 267 + const cx = W / 2, 268 + cy = H / 2; 269 + ctx.font = '9px "Saira Condensed", sans-serif'; 270 + ctx.textAlign = 'left'; 271 + ctx.textBaseline = 'middle'; 272 + for (const { r, label } of RING_LABELS) { 273 + const y = cy - r * u; 274 + // short tick + dash 275 + ctx.strokeStyle = resolve('--hud-mid'); 276 + ctx.lineWidth = 1; 277 + ctx.beginPath(); 278 + ctx.moveTo(cx - 4, y); 279 + ctx.lineTo(cx + 4, y); 280 + ctx.stroke(); 281 + ctx.fillStyle = resolve('--text-dim'); 282 + ctx.fillText(label.toUpperCase(), cx + 10, y); 283 + } 284 + } 285 + 286 + function drawAnchorSpokes(ctx: CanvasRenderingContext2D) { 287 + // Subtle radial lines from phi → anchors (goals) and attention (observations). 288 + // These are the only "edges" — they show what's tethered to phi's center. 289 + const cx = W / 2, 290 + cy = H / 2; 291 + ctx.strokeStyle = resolve('--line-dim'); 292 + ctx.lineWidth = 1; 293 + for (const p of points) { 294 + if (p.kind !== 'goal' && p.kind !== 'observation') continue; 295 + const [sx, sy] = worldToScreen(p.x, p.y); 296 + ctx.beginPath(); 297 + ctx.moveTo(cx, cy); 298 + ctx.lineTo(sx, sy); 299 + ctx.stroke(); 300 + } 301 + } 302 + 303 + function drawPoints(ctx: CanvasRenderingContext2D) { 304 + for (const p of points) { 305 + const [sx, sy] = worldToScreen(p.x, p.y); 306 + const r = radiusFor(p); 307 + 308 + if (p.kind === 'phi') drawPhi(ctx, sx, sy, r, p); 309 + else if (p.kind === 'handle-engaged') drawKnown(ctx, sx, sy, r, p); 310 + else if (p.kind === 'handle-candidate') drawCandidate(ctx, sx, sy, r, p); 311 + else if (p.kind === 'goal') drawHex(ctx, sx, sy, r, '--warn'); 312 + else if (p.kind === 'observation') drawHex(ctx, sx, sy, r, '--scan-mid'); 313 + } 314 + } 315 + 316 + function drawHex( 317 + ctx: CanvasRenderingContext2D, 318 + cx: number, 319 + cy: number, 320 + r: number, 321 + colorVar: string 322 + ) { 323 + ctx.fillStyle = resolve(colorVar); 324 + ctx.beginPath(); 325 + // Pointy-top hex 326 + for (let i = 0; i < 6; i++) { 327 + const a = (-Math.PI / 2) + (i * Math.PI) / 3; 328 + const x = cx + Math.cos(a) * r; 329 + const y = cy + Math.sin(a) * r; 330 + if (i === 0) ctx.moveTo(x, y); 331 + else ctx.lineTo(x, y); 332 + } 333 + ctx.closePath(); 334 + ctx.fill(); 335 + } 336 + 337 + function drawPhi( 338 + ctx: CanvasRenderingContext2D, 339 + cx: number, 340 + cy: number, 341 + r: number, 342 + p: AtlasPoint 343 + ) { 344 + // Glow halo 345 + const grd = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 2.2); 346 + grd.addColorStop(0, resolve('--hud-hot')); 347 + grd.addColorStop(1, 'rgba(184,107,58,0)'); 348 + ctx.fillStyle = grd; 349 + ctx.beginPath(); 350 + ctx.arc(cx, cy, r * 2.2, 0, Math.PI * 2); 351 + ctx.fill(); 352 + 353 + // Hexagonal frame 354 + const drawHexPath = (radius: number) => { 355 + ctx.beginPath(); 356 + for (let i = 0; i < 6; i++) { 357 + const a = (-Math.PI / 2) + (i * Math.PI) / 3; 358 + const x = cx + Math.cos(a) * radius; 359 + const y = cy + Math.sin(a) * radius; 360 + if (i === 0) ctx.moveTo(x, y); 361 + else ctx.lineTo(x, y); 362 + } 363 + ctx.closePath(); 364 + }; 365 + 366 + // Avatar inside, clipped to hex 367 + const img = p.avatar ? imageCache.get(p.avatar) : null; 368 + if (img) { 369 + ctx.save(); 370 + drawHexPath(r); 371 + ctx.clip(); 372 + ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2); 373 + ctx.restore(); 374 + } else { 375 + ctx.fillStyle = resolve('--hud-hot'); 376 + drawHexPath(r); 377 + ctx.fill(); 378 + } 379 + 380 + // Outer hex ring 381 + ctx.strokeStyle = resolve('--hud-hot'); 382 + ctx.lineWidth = 1.5; 383 + drawHexPath(r); 384 + ctx.stroke(); 385 + 386 + // Label below 387 + ctx.font = '10px "Saira Condensed", sans-serif'; 388 + ctx.fillStyle = resolve('--hud-hot'); 389 + ctx.textAlign = 'center'; 390 + ctx.textBaseline = 'top'; 391 + ctx.fillText('PHI', cx, cy + r + 6); 392 + } 393 + 394 + function drawKnown( 395 + ctx: CanvasRenderingContext2D, 396 + cx: number, 397 + cy: number, 398 + r: number, 399 + p: AtlasPoint 400 + ) { 401 + const img = p.avatar ? imageCache.get(p.avatar) : null; 402 + ctx.save(); 403 + ctx.beginPath(); 404 + ctx.arc(cx, cy, r, 0, Math.PI * 2); 405 + if (img) { 406 + ctx.clip(); 407 + ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2); 408 + } else { 409 + ctx.fillStyle = resolve('--text-mid'); 410 + ctx.fill(); 411 + } 412 + ctx.restore(); 413 + // outline 414 + ctx.strokeStyle = img ? resolve('--text') : resolve('--text-mid'); 415 + ctx.lineWidth = 1.2; 416 + ctx.beginPath(); 417 + ctx.arc(cx, cy, r, 0, Math.PI * 2); 418 + ctx.stroke(); 419 + } 420 + 421 + function drawCandidate( 422 + ctx: CanvasRenderingContext2D, 423 + cx: number, 424 + cy: number, 425 + r: number, 426 + _p: AtlasPoint 427 + ) { 428 + ctx.strokeStyle = resolve('--text-dim'); 429 + ctx.lineWidth = 1; 430 + ctx.setLineDash([2, 2]); 431 + ctx.beginPath(); 432 + ctx.arc(cx, cy, r, 0, Math.PI * 2); 433 + ctx.stroke(); 434 + ctx.setLineDash([]); 435 + } 436 + 437 + function drawReticle(ctx: CanvasRenderingContext2D, p: AtlasPoint) { 438 + const [cx, cy] = worldToScreen(p.x, p.y); 439 + const r = radiusFor(p) + 4; 440 + ctx.strokeStyle = resolve('--hud-hot'); 441 + ctx.lineWidth = 1.2; 442 + const arm = 6; 443 + for (const [sx, sy] of [ 444 + [-1, -1], 445 + [1, -1], 446 + [-1, 1], 447 + [1, 1] 448 + ]) { 449 + const x = cx + sx * r; 450 + const y = cy + sy * r; 451 + ctx.beginPath(); 452 + ctx.moveTo(x, y - sy * arm); 453 + ctx.lineTo(x, y); 454 + ctx.lineTo(x - sx * arm, y); 455 + ctx.stroke(); 456 + } 457 + } 458 + 459 + // ---- input ---- 460 + 461 + function pointAt(mx: number, my: number): AtlasPoint | null { 462 + let best: AtlasPoint | null = null; 463 + let bestD = Infinity; 464 + for (const p of points) { 465 + const [sx, sy] = worldToScreen(p.x, p.y); 466 + const r = radiusFor(p); 467 + const dx = sx - mx, 468 + dy = sy - my; 469 + const d2 = dx * dx + dy * dy; 470 + const hit = (r + 6) * (r + 6); 471 + if (d2 < hit && d2 < bestD) { 472 + bestD = d2; 473 + best = p; 474 + } 475 + } 476 + return best; 477 + } 478 + 479 + function readoutFor(p: AtlasPoint): string { 480 + const labels: Record<string, string> = { 481 + phi: 'self', 482 + 'handle-engaged': 'in memory', 483 + 'handle-candidate': 'on horizon', 484 + goal: 'anchor', 485 + observation: 'attention' 486 + }; 487 + return `${labels[p.kind] ?? p.kind} · ${p.label}`; 488 + } 489 + 490 + function entryFor(p: AtlasPoint): LogbookEntry | null { 491 + if (p.kind === 'phi') return null; 492 + if (p.kind === 'handle-engaged') { 493 + const pl = p.payload as { handle: string }; 494 + return { 495 + kind: 'handle', 496 + handle: pl.handle, 497 + engaged: true, 498 + payload: pl 499 + }; 500 + } 501 + if (p.kind === 'handle-candidate') { 502 + const pl = p.payload as { handle: string; did: string; entry: DiscoveryEntry }; 503 + return { kind: 'discovery', entry: pl.entry }; 504 + } 505 + if (p.kind === 'goal') return { kind: 'goal', goal: p.payload as Goal }; 506 + if (p.kind === 'observation') 507 + return { kind: 'observation', observation: p.payload as Observation }; 508 + return null; 509 + } 510 + 511 + function onPointerMove(e: PointerEvent) { 512 + const rect = canvas.getBoundingClientRect(); 513 + const mx = e.clientX - rect.left; 514 + const my = e.clientY - rect.top; 515 + const p = pointAt(mx, my); 516 + if (p !== hovered) { 517 + hovered = p; 518 + hudReadout.set(p ? readoutFor(p) : ''); 519 + canvas.style.cursor = p && p.kind !== 'phi' ? 'pointer' : 'default'; 520 + scheduleFrame(); 521 + } 522 + } 523 + 524 + function onClick(e: MouseEvent) { 525 + const rect = canvas.getBoundingClientRect(); 526 + const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 527 + if (!p) return; 528 + const entry = entryFor(p); 529 + if (entry) logbook.set(entry); 530 + } 531 + 532 + // ---- resize ---- 533 + 534 + let ro: ResizeObserver | null = null; 535 + function resize() { 536 + if (!canvas) return; 537 + const rect = canvas.parentElement!.getBoundingClientRect(); 538 + W = rect.width; 539 + H = rect.height; 540 + dpr = window.devicePixelRatio || 1; 541 + canvas.width = W * dpr; 542 + canvas.height = H * dpr; 543 + canvas.style.width = `${W}px`; 544 + canvas.style.height = `${H}px`; 545 + scheduleFrame(); 546 + } 547 + 548 + onMount(() => { 549 + resize(); 550 + ro = new ResizeObserver(resize); 551 + if (canvas?.parentElement) ro.observe(canvas.parentElement); 552 + }); 553 + 554 + onDestroy(() => { 555 + ro?.disconnect(); 556 + hudReadout.set(''); 557 + }); 558 + </script> 559 + 560 + <div class="host"> 561 + <canvas 562 + bind:this={canvas} 563 + onpointermove={onPointerMove} 564 + onpointerleave={() => { 565 + hovered = null; 566 + hudReadout.set(''); 567 + canvas.style.cursor = 'default'; 568 + scheduleFrame(); 569 + }} 570 + onclick={onClick} 571 + ></canvas> 572 + </div> 573 + 574 + <style> 575 + .host { 576 + position: absolute; 577 + inset: 0; 578 + } 579 + 580 + canvas { 581 + display: block; 582 + touch-action: none; 583 + } 584 + </style>
+29
web/src/lib/types.ts
··· 94 94 operator_only: boolean; 95 95 } 96 96 97 + // --- /api/users/{handle} --- 98 + 99 + export interface UserViewObservation { 100 + content: string; 101 + tags: string[]; 102 + created_at: string | null; 103 + source_uris: string[]; 104 + } 105 + 106 + export interface UserViewSummary { 107 + content: string; 108 + created_at: string | null; 109 + } 110 + 111 + export interface UserView { 112 + handle: string; 113 + did: string | null; 114 + is_stranger: boolean; 115 + counts: { 116 + observation: number; 117 + interaction: number; 118 + summary: number; 119 + }; 120 + first_seen: string | null; 121 + last_seen: string | null; 122 + summary: UserViewSummary | null; 123 + recent_observations: UserViewObservation[]; 124 + } 125 + 97 126 // --- bsky public API minimal types (used by feed/blog) --- 98 127 99 128 export interface BskyAuthor {
+60 -154
web/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import Atlas from '$lib/components/Atlas.svelte'; 3 + import MindMap from '$lib/components/MindMap.svelte'; 4 4 import Logbook from '$lib/components/Logbook.svelte'; 5 5 import { 6 6 getMemoryGraph, ··· 9 9 getGoals, 10 10 PHI_HANDLE 11 11 } from '$lib/api'; 12 - import type { AtlasPoint, LogbookEntry, GraphNode, DiscoveryEntry, Observation, Goal } from '$lib/types'; 12 + import type { GraphNode, DiscoveryEntry, Observation, Goal } from '$lib/types'; 13 13 14 - let points = $state<AtlasPoint[]>([]); 15 - let edges = $state<{ source: string; target: string }[]>([]); 14 + let goals = $state<Goal[]>([]); 15 + let observations = $state<Observation[]>([]); 16 + let known = $state<GraphNode[]>([]); 17 + let candidates = $state<DiscoveryEntry[]>([]); 18 + let avatars = $state<Record<string, string>>({}); 16 19 let loaded = $state(false); 17 20 let err = $state<string | null>(null); 18 21 ··· 36 39 return map; 37 40 } 38 41 39 - function entryFor(p: AtlasPoint): LogbookEntry { 40 - if (p.kind === 'handle-engaged' || p.kind === 'handle-candidate') { 41 - const pl = p.payload as { handle: string; did?: string }; 42 - return { 43 - kind: 'handle', 44 - handle: pl.handle, 45 - did: pl.did, 46 - engaged: p.kind === 'handle-engaged', 47 - payload: pl 48 - }; 49 - } 50 - if (p.kind === 'observation') return { kind: 'observation', observation: p.payload as Observation }; 51 - if (p.kind === 'goal') return { kind: 'goal', goal: p.payload as Goal }; 52 - // phi has no logbook (own self) 53 - return { kind: 'handle', handle: PHI_HANDLE, engaged: true, payload: {} }; 54 - } 55 - 56 - function jitter(seed: string, amplitude: number): [number, number] { 57 - // deterministic position based on string hash, in [-amp, amp] 58 - let h1 = 2166136261; 59 - let h2 = 5381; 60 - for (let i = 0; i < seed.length; i++) { 61 - h1 ^= seed.charCodeAt(i); 62 - h1 = Math.imul(h1, 16777619); 63 - h2 = ((h2 << 5) + h2 + seed.charCodeAt(i)) | 0; 64 - } 65 - const a = ((h1 >>> 0) % 10000) / 10000; // 0..1 66 - const b = ((h2 >>> 0) % 10000) / 10000; 67 - const angle = a * Math.PI * 2; 68 - const r = b * amplitude; 69 - return [Math.cos(angle) * r, Math.sin(angle) * r]; 70 - } 71 - 72 42 onMount(async () => { 73 43 try { 74 44 const [graphR, discR, obsR, goalsR] = await Promise.allSettled([ ··· 78 48 getGoals() 79 49 ]); 80 50 81 - const handles: string[] = [PHI_HANDLE]; 82 - const pts: AtlasPoint[] = []; 83 - 84 51 if (graphR.status === 'fulfilled') { 85 - const data = graphR.value; 86 - for (const n of data.nodes as GraphNode[]) { 87 - if (n.type === 'phi') { 88 - pts.push({ 89 - id: n.id, 90 - kind: 'phi', 91 - label: 'phi', 92 - x: 0, 93 - y: 0, 94 - payload: {} 95 - }); 96 - } else { 97 - const handle = n.label.replace(/^@/, ''); 98 - handles.push(handle); 99 - pts.push({ 100 - id: n.id, 101 - kind: 'handle-engaged', 102 - label: n.label, 103 - x: n.x ?? 0, 104 - y: n.y ?? 0, 105 - payload: { handle } 106 - }); 107 - } 108 - } 109 - edges = data.edges.map((e) => ({ source: e.source, target: e.target })); 110 - } 111 - 112 - if (discR.status === 'fulfilled') { 113 - const cands = discR.value as DiscoveryEntry[]; 114 - const engagedHandles = new Set( 115 - pts.filter((p) => p.kind === 'handle-engaged').map((p) => (p.payload as { handle: string }).handle) 116 - ); 117 - for (const c of cands) { 118 - if (engagedHandles.has(c.handle)) continue; // already in graph 119 - handles.push(c.handle); 120 - // position candidates on the periphery (radius ~0.85 from center) 121 - const [jx, jy] = jitter(c.handle, 0.85); 122 - pts.push({ 123 - id: `cand-${c.did}`, 124 - kind: 'handle-candidate', 125 - label: `@${c.handle}`, 126 - x: jx, 127 - y: jy, 128 - payload: { handle: c.handle, did: c.did, entry: c } 129 - }); 130 - } 131 - } 132 - 133 - if (obsR.status === 'fulfilled') { 134 - const obs = obsR.value as Observation[]; 135 - for (const o of obs) { 136 - const [jx, jy] = jitter(o.rkey, 0.45); 137 - pts.push({ 138 - id: `obs-${o.rkey}`, 139 - kind: 'observation', 140 - label: o.content, 141 - x: jx, 142 - y: jy, 143 - payload: o 144 - }); 145 - } 146 - } 147 - 148 - if (goalsR.status === 'fulfilled') { 149 - const goals = goalsR.value as Goal[]; 150 - for (const g of goals) { 151 - const [jx, jy] = jitter(g.rkey, 0.65); 152 - pts.push({ 153 - id: `goal-${g.rkey}`, 154 - kind: 'goal', 155 - label: g.title, 156 - x: jx, 157 - y: jy, 158 - payload: g 159 - }); 160 - } 161 - } 162 - 163 - // load avatars and attach to points 164 - const avatars = await fetchAvatars(handles); 165 - for (const p of pts) { 166 - if (p.kind === 'handle-engaged' || p.kind === 'handle-candidate' || p.kind === 'phi') { 167 - const handle = 168 - p.kind === 'phi' 169 - ? PHI_HANDLE 170 - : (p.payload as { handle: string }).handle; 171 - p.avatar = avatars[handle] ?? null; 172 - } 52 + known = graphR.value.nodes.filter((n) => n.type === 'user') as GraphNode[]; 173 53 } 54 + if (discR.status === 'fulfilled') candidates = discR.value; 55 + if (obsR.status === 'fulfilled') observations = obsR.value; 56 + if (goalsR.status === 'fulfilled') goals = goalsR.value; 174 57 175 - points = pts; 176 - loaded = true; 58 + const handles = new Set<string>([PHI_HANDLE]); 59 + for (const n of known) handles.add(n.label.replace(/^@/, '')); 60 + for (const c of candidates) handles.add(c.handle); 61 + avatars = await fetchAvatars([...handles]); 177 62 } catch (e) { 178 63 err = (e as Error).message; 64 + } finally { 179 65 loaded = true; 180 66 } 181 67 }); ··· 189 75 {#if !loaded} 190 76 <div class="overlay chrome muted">acquiring map…</div> 191 77 {:else if err} 192 - <div class="overlay chrome muted">signal lost · {err}</div> 193 - {:else if points.length === 0} 194 - <div class="overlay chrome muted">empty map · no objects in attention</div> 78 + <div class="overlay chrome muted">connection lost · {err}</div> 195 79 {:else} 196 - <Atlas {points} {edges} {entryFor} /> 80 + <MindMap {goals} {observations} {known} {candidates} {avatars} /> 197 81 {/if} 198 82 199 - <div class="legend chrome"> 200 - <span class="li"><span class="hex" style="color: var(--hud-hot)"></span>self</span> 201 - <span class="li"><span class="hex" style="color: var(--text)"></span>in memory</span> 202 - <span class="li" 203 - ><span class="hex ring" style="color: var(--text-dim)"></span>on radar</span 204 - > 205 - <span class="li"><span class="hex" style="color: var(--scan-mid)"></span>attention</span> 206 - <span class="li"><span class="hex" style="color: var(--warn)"></span>goal</span> 83 + <!-- Bottom-of-map orientation key --> 84 + <div class="key chrome"> 85 + <span class="kii"><span class="hex" style="color: var(--hud-hot)"></span>self</span> 86 + <span class="sep"></span> 87 + <span class="kii"><span class="hex" style="color: var(--warn)"></span>anchor</span> 88 + <span class="kii"><span class="hex" style="color: var(--scan-mid)"></span>attention</span> 89 + <span class="kii"><span class="dot solid"></span>known</span> 90 + <span class="kii"><span class="dot dashed"></span>horizon</span> 207 91 </div> 208 92 </div> 209 93 ··· 226 110 letter-spacing: 0.18em; 227 111 } 228 112 229 - .legend { 113 + .key { 230 114 position: absolute; 231 115 bottom: 60px; 232 116 left: 50%; 233 117 transform: translateX(-50%); 234 118 display: flex; 235 - gap: 18px; 119 + gap: 14px; 236 120 font-size: 10px; 237 121 color: var(--text-dim); 238 122 background: var(--bg-panel); 239 123 border: 1px solid var(--line-mid); 240 124 backdrop-filter: blur(8px); 241 125 -webkit-backdrop-filter: blur(8px); 242 - padding: 8px 14px; 126 + padding: 7px 14px; 243 127 pointer-events: none; 244 128 clip-path: polygon( 245 129 6px 0, ··· 251 135 ); 252 136 } 253 137 138 + .kii { 139 + display: flex; 140 + align-items: center; 141 + gap: 6px; 142 + } 143 + 144 + .sep { 145 + width: 1px; 146 + height: 10px; 147 + background: var(--line-mid); 148 + } 149 + 150 + .dot { 151 + width: 8px; 152 + height: 8px; 153 + border-radius: 50%; 154 + display: inline-block; 155 + } 156 + 157 + .dot.solid { 158 + background: var(--text); 159 + } 160 + 161 + .dot.dashed { 162 + background: transparent; 163 + border: 1px dashed var(--text-dim); 164 + } 165 + 254 166 @media (max-width: 640px) { 255 - .legend { 167 + .key { 256 168 bottom: 44px; 257 - gap: 10px; 169 + gap: 8px; 258 170 font-size: 9px; 259 - padding: 6px 10px; 171 + padding: 5px 10px; 260 172 max-width: calc(100vw - 16px); 261 173 flex-wrap: wrap; 262 174 justify-content: center; 263 175 } 264 - } 265 - 266 - .li { 267 - display: flex; 268 - align-items: center; 269 - gap: 6px; 270 176 } 271 177 </style>
+395 -83
web/src/routes/capabilities/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { CAPABILITIES, OPERATOR_ONLY } from '$lib/abilities'; 2 + import { onMount } from 'svelte'; 3 + import { getCapabilities } from '$lib/api'; 4 + import type { Capability } from '$lib/types'; 5 + 6 + let caps = $state<Capability[]>([]); 7 + let selectedIdx = $state(0); 8 + let loaded = $state(false); 9 + let err = $state<string | null>(null); 3 10 4 - const sorted = [...CAPABILITIES].sort(); 5 - const total = sorted.length; 6 - const operatorCount = sorted.filter((n) => OPERATOR_ONLY.has(n)).length; 11 + const sorted = $derived([...caps].sort((a, b) => a.name.localeCompare(b.name))); 12 + const total = $derived(sorted.length); 13 + const opCount = $derived(sorted.filter((c) => c.operator_only).length); 14 + const selected = $derived(sorted[selectedIdx] ?? null); 15 + 16 + onMount(async () => { 17 + try { 18 + caps = await getCapabilities(); 19 + selectedIdx = 0; 20 + } catch (e) { 21 + err = (e as Error).message; 22 + } finally { 23 + loaded = true; 24 + } 25 + }); 26 + 27 + function pick(i: number) { 28 + selectedIdx = i; 29 + } 30 + 31 + function onListKey(e: KeyboardEvent) { 32 + if (e.key === 'ArrowDown' && selectedIdx < total - 1) { 33 + e.preventDefault(); 34 + selectedIdx++; 35 + } else if (e.key === 'ArrowUp' && selectedIdx > 0) { 36 + e.preventDefault(); 37 + selectedIdx--; 38 + } 39 + } 40 + 41 + function pad(n: number, w = 2): string { 42 + return String(n + 1).padStart(w, '0'); 43 + } 7 44 </script> 8 45 9 46 <svelte:head> ··· 11 48 </svelte:head> 12 49 13 50 <div class="lens"> 14 - <div class="screen"> 15 - <header> 16 - <h1 class="chrome">what i can do</h1> 17 - <p class="line"> 18 - <span class="num mono">{total}</span> 19 - <span class="lbl">things i can do</span> 20 - {#if operatorCount > 0} 21 - <span class="sep">·</span> 22 - <span class="num mono">{operatorCount}</span> 23 - <span class="lbl" 24 - >{operatorCount === 1 ? 'requires' : 'require'} nate's authorization</span 51 + <div class="frame-wrap"> 52 + <header class="head"> 53 + <div class="head-rule"> 54 + <span class="head-tag chrome">phi · capabilities</span> 55 + <span class="head-status chrome"> 56 + {#if loaded && !err} 57 + <span class="num mono">{pad(total - 1)}</span> 58 + <span class="dim">entries</span> 59 + {#if opCount > 0} 60 + <span class="seg"></span> 61 + <span class="num mono">{pad(opCount - 1)}</span> 62 + <span class="dim">operator-gated</span> 63 + {/if} 64 + {:else if err} 65 + <span class="dim">connection lost</span> 66 + {:else} 67 + <span class="dim">acquiring…</span> 68 + {/if} 69 + </span> 70 + </div> 71 + <h1 class="title chrome">what i can do</h1> 72 + </header> 73 + 74 + <div class="panes"> 75 + <!-- list pane --> 76 + <aside class="list-pane"> 77 + <div class="pane-rule chrome">capabilities</div> 78 + {#if !loaded} 79 + <div class="empty chrome muted">acquiring…</div> 80 + {:else if err} 81 + <div class="empty chrome muted">unreachable · {err}</div> 82 + {:else if sorted.length === 0} 83 + <div class="empty chrome muted">none registered</div> 84 + {:else} 85 + <ul 86 + class="list scroll" 87 + role="listbox" 88 + tabindex="0" 89 + aria-label="capabilities" 90 + onkeydown={onListKey} 25 91 > 92 + {#each sorted as cap, i (cap.name)} 93 + <li> 94 + <button 95 + class="row" 96 + class:active={i === selectedIdx} 97 + role="option" 98 + aria-selected={i === selectedIdx} 99 + onclick={() => pick(i)} 100 + > 101 + <span class="bar" aria-hidden="true"></span> 102 + <span class="idx mono">{pad(i)}</span> 103 + <span class="name mono">{cap.name}</span> 104 + {#if cap.operator_only} 105 + <span class="op-dot" title="requires nate's authorization"></span> 106 + {/if} 107 + </button> 108 + </li> 109 + {/each} 110 + </ul> 26 111 {/if} 27 - </p> 28 - </header> 112 + </aside> 29 113 30 - <ul class="list"> 31 - {#each sorted as name (name)} 32 - <li> 33 - <span class="hex" style="color: var(--hud-mid)"></span> 34 - <span class="name mono">{name}</span> 35 - {#if OPERATOR_ONLY.has(name)} 36 - <span class="op chrome">operator</span> 37 - {/if} 38 - </li> 39 - {/each} 40 - </ul> 114 + <!-- detail pane --> 115 + <section class="detail-pane"> 116 + <div class="pane-rule chrome">readout</div> 117 + {#if selected} 118 + <div class="detail scroll"> 119 + <div class="d-head"> 120 + <div class="d-name mono">{selected.name}</div> 121 + <div class="d-meta chrome"> 122 + <span class="dim">entry</span> 123 + <span class="num mono">{pad(selectedIdx)}</span> 124 + <span class="dim">of</span> 125 + <span class="num mono">{pad(total - 1)}</span> 126 + {#if selected.operator_only} 127 + <span class="seg"></span> 128 + <span class="op-tag chrome">operator-gated</span> 129 + {/if} 130 + </div> 131 + </div> 132 + <div class="d-rule"></div> 133 + {#if selected.description} 134 + <div class="d-body"> 135 + {#each selected.description.split(/\n\s*\n/) as para, i (i)} 136 + <p>{para}</p> 137 + {/each} 138 + </div> 139 + {:else} 140 + <div class="d-body muted">no description recorded.</div> 141 + {/if} 142 + </div> 143 + {:else} 144 + <div class="empty chrome muted">no entry selected</div> 145 + {/if} 146 + </section> 147 + </div> 41 148 </div> 42 149 </div> 43 150 ··· 47 154 inset: 0; 48 155 display: flex; 49 156 justify-content: center; 50 - padding: 84px 32px 64px; 157 + padding: 76px 28px 56px; 51 158 overflow: hidden; 52 159 } 53 160 54 - .screen { 161 + .frame-wrap { 55 162 position: relative; 56 163 width: 100%; 57 - max-width: 680px; 58 - max-height: 100%; 59 - overflow-y: auto; 60 - padding: 28px 36px 32px; 61 - background: var(--bg-deep); 164 + max-width: 980px; 165 + height: 100%; 166 + display: grid; 167 + grid-template-rows: auto 1fr; 168 + gap: 14px; 169 + background: 170 + linear-gradient(180deg, rgba(20, 26, 38, 0.55) 0%, rgba(13, 17, 25, 0.4) 100%), 171 + var(--bg-deep); 62 172 border: 1px solid var(--line-mid); 63 173 clip-path: polygon( 64 - 14px 0, 174 + 16px 0, 65 175 100% 0, 66 - 100% calc(100% - 14px), 67 - calc(100% - 14px) 100%, 176 + 100% calc(100% - 16px), 177 + calc(100% - 16px) 100%, 68 178 0 100%, 69 - 0 14px 179 + 0 16px 70 180 ); 181 + padding: 18px 20px 16px; 182 + box-shadow: 183 + inset 0 0 60px rgba(184, 107, 58, 0.04), 184 + inset 1px 0 0 rgba(184, 107, 58, 0.05); 71 185 } 72 186 73 - /* corner brackets — purely decorative chrome */ 74 - .screen::before, 75 - .screen::after { 187 + /* anchored corner brackets */ 188 + .frame-wrap::before, 189 + .frame-wrap::after { 76 190 content: ''; 77 191 position: absolute; 78 - width: 14px; 79 - height: 14px; 80 - border-color: var(--hud-mid); 192 + width: 18px; 193 + height: 18px; 194 + border-color: var(--hud-hot); 81 195 border-style: solid; 82 196 border-width: 0; 83 197 pointer-events: none; 198 + opacity: 0.7; 84 199 } 85 - .screen::before { 200 + .frame-wrap::before { 86 201 top: 4px; 87 202 left: 4px; 88 203 border-top-width: 1px; 89 204 border-left-width: 1px; 90 205 } 91 - .screen::after { 206 + .frame-wrap::after { 92 207 bottom: 4px; 93 208 right: 4px; 94 209 border-bottom-width: 1px; 95 210 border-right-width: 1px; 96 211 } 97 212 98 - header { 99 - margin-bottom: 22px; 100 - padding-bottom: 16px; 213 + /* ---------- header ---------- */ 214 + 215 + .head { 216 + display: flex; 217 + flex-direction: column; 218 + gap: 8px; 219 + } 220 + 221 + .head-rule { 222 + display: flex; 223 + justify-content: space-between; 224 + align-items: baseline; 225 + font-size: 9px; 226 + gap: 12px; 227 + padding-bottom: 8px; 101 228 border-bottom: 1px solid var(--line-dim); 102 229 } 103 230 104 - h1 { 105 - font-size: 24px; 106 - font-weight: 500; 107 - margin: 0 0 8px 0; 108 - color: var(--text); 109 - letter-spacing: 0.1em; 231 + .head-tag { 232 + color: var(--hud-hot); 233 + letter-spacing: 0.22em; 110 234 } 111 235 112 - .line { 236 + .head-status { 113 237 display: flex; 114 238 gap: 6px; 115 - flex-wrap: wrap; 116 239 align-items: baseline; 117 - font-size: 12px; 118 - color: var(--text-mid); 119 - margin: 0; 240 + color: var(--scan-mid); 241 + letter-spacing: 0.1em; 242 + } 243 + 244 + .dim { 245 + color: var(--text-dim); 246 + font-size: 9px; 120 247 } 121 248 122 249 .num { 123 250 color: var(--scan-hot); 124 - font-size: 13px; 251 + font-size: 10px; 125 252 } 126 253 127 - .lbl { 128 - font-size: 12px; 254 + .seg { 255 + display: inline-block; 256 + width: 1px; 257 + height: 8px; 258 + background: var(--line-mid); 259 + margin: 0 4px; 260 + vertical-align: middle; 129 261 } 130 262 131 - .sep { 263 + .title { 264 + font-size: 28px; 265 + font-weight: 500; 266 + letter-spacing: 0.12em; 267 + color: var(--text); 268 + margin: 0; 269 + line-height: 1; 270 + } 271 + 272 + /* ---------- panes ---------- */ 273 + 274 + .panes { 275 + display: grid; 276 + grid-template-columns: 320px 1fr; 277 + gap: 12px; 278 + min-height: 0; 279 + } 280 + 281 + .list-pane, 282 + .detail-pane { 283 + display: flex; 284 + flex-direction: column; 285 + min-height: 0; 286 + background: var(--bg-void); 287 + border: 1px solid var(--line-dim); 288 + } 289 + 290 + .pane-rule { 291 + font-size: 9px; 132 292 color: var(--text-dim); 293 + letter-spacing: 0.22em; 294 + padding: 6px 12px; 295 + border-bottom: 1px solid var(--line-dim); 296 + background: linear-gradient( 297 + 90deg, 298 + rgba(184, 107, 58, 0.08) 0%, 299 + rgba(184, 107, 58, 0) 90% 300 + ); 133 301 } 134 302 303 + .empty { 304 + flex: 1; 305 + display: flex; 306 + align-items: center; 307 + justify-content: center; 308 + font-size: 10px; 309 + letter-spacing: 0.18em; 310 + } 311 + 312 + /* ---------- list ---------- */ 313 + 135 314 .list { 315 + flex: 1; 136 316 list-style: none; 137 - padding: 0; 317 + padding: 4px 0; 138 318 margin: 0; 139 - display: grid; 140 - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 141 - gap: 4px 28px; 319 + overflow-y: auto; 320 + outline: none; 142 321 } 143 322 144 - li { 323 + .list:focus-visible { 324 + box-shadow: inset 0 0 0 1px var(--hud-mid); 325 + } 326 + 327 + .row { 145 328 display: flex; 146 329 align-items: center; 147 330 gap: 10px; 148 - padding: 4px 0; 149 - font-size: 13px; 331 + width: 100%; 332 + padding: 6px 12px 6px 8px; 333 + background: transparent; 334 + border: none; 335 + border-radius: 0; 336 + font-family: inherit; 337 + text-transform: none; 338 + letter-spacing: 0; 339 + text-align: left; 340 + color: var(--text-mid); 341 + cursor: pointer; 342 + transition: 343 + color 0.12s, 344 + background 0.12s; 345 + min-height: 30px; 346 + } 347 + 348 + .row .bar { 349 + display: block; 350 + width: 2px; 351 + height: 22px; 352 + background: transparent; 353 + flex-shrink: 0; 354 + transition: background 0.12s; 355 + } 356 + 357 + .row:hover { 358 + color: var(--text); 359 + background: rgba(184, 107, 58, 0.04); 360 + } 361 + 362 + .row.active { 363 + color: var(--hud-hot); 364 + background: linear-gradient( 365 + 90deg, 366 + rgba(184, 107, 58, 0.18) 0%, 367 + rgba(184, 107, 58, 0.04) 60%, 368 + transparent 100% 369 + ); 370 + } 371 + 372 + .row.active .bar { 373 + background: var(--hud-hot); 374 + box-shadow: 0 0 6px rgba(224, 144, 96, 0.6); 375 + } 376 + 377 + .idx { 378 + font-size: 10px; 379 + color: var(--text-dim); 380 + flex-shrink: 0; 381 + } 382 + 383 + .row.active .idx { 384 + color: var(--hud-mid); 150 385 } 151 386 152 387 .name { 388 + flex: 1; 389 + font-size: 12px; 390 + overflow: hidden; 391 + text-overflow: ellipsis; 392 + white-space: nowrap; 393 + } 394 + 395 + .op-dot { 396 + display: inline-block; 397 + width: 6px; 398 + height: 6px; 399 + background: var(--warn); 400 + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); 401 + flex-shrink: 0; 402 + opacity: 0.8; 403 + } 404 + 405 + /* ---------- detail ---------- */ 406 + 407 + .detail { 408 + flex: 1; 409 + padding: 18px 22px 22px; 410 + overflow-y: auto; 411 + display: flex; 412 + flex-direction: column; 413 + gap: 12px; 414 + } 415 + 416 + .d-head { 417 + display: flex; 418 + flex-direction: column; 419 + gap: 6px; 420 + } 421 + 422 + .d-name { 423 + font-size: 22px; 153 424 color: var(--text); 154 - font-size: 12px; 425 + text-transform: none; 426 + letter-spacing: 0.02em; 427 + line-height: 1.1; 428 + } 429 + 430 + .d-meta { 431 + display: flex; 432 + gap: 6px; 433 + align-items: baseline; 434 + font-size: 9px; 435 + letter-spacing: 0.18em; 436 + color: var(--text-dim); 155 437 } 156 438 157 - .op { 158 - font-size: 8px; 439 + .op-tag { 159 440 color: var(--warn); 160 - letter-spacing: 0.18em; 161 - margin-left: 2px; 441 + font-size: 9px; 442 + letter-spacing: 0.22em; 443 + } 444 + 445 + .d-rule { 446 + height: 1px; 447 + background: repeating-linear-gradient( 448 + 90deg, 449 + var(--hud-mid) 0px, 450 + var(--hud-mid) 8px, 451 + transparent 8px, 452 + transparent 14px 453 + ); 454 + opacity: 0.6; 455 + } 456 + 457 + .d-body { 458 + font-size: 13px; 459 + line-height: 1.65; 460 + color: var(--text); 461 + } 462 + 463 + .d-body p { 464 + margin: 0 0 12px 0; 465 + white-space: pre-wrap; 466 + } 467 + 468 + .d-body p:last-child { 469 + margin-bottom: 0; 162 470 } 163 471 164 - @media (max-width: 640px) { 472 + /* ---------- mobile ---------- */ 473 + 474 + @media (max-width: 720px) { 165 475 .lens { 166 476 padding: 64px 12px 52px; 167 477 } 168 - .screen { 169 - padding: 18px 20px 22px; 478 + .frame-wrap { 479 + padding: 12px 14px 14px; 480 + gap: 10px; 170 481 } 171 - h1 { 172 - font-size: 20px; 482 + .title { 483 + font-size: 22px; 173 484 } 174 - .list { 485 + .panes { 175 486 grid-template-columns: 1fr; 487 + grid-template-rows: minmax(180px, 38vh) 1fr; 176 488 } 177 489 } 178 490 </style>