diff --git a/examples/cards/templates/todo-item-live/assets/dosis-400.ttf b/examples/cards/templates/todo-item-live/assets/dosis-400.ttf
new file mode 100644
index 0000000..be2253b
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/dosis-400.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/dosis-600.ttf b/examples/cards/templates/todo-item-live/assets/dosis-600.ttf
new file mode 100644
index 0000000..b254503
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/dosis-600.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/dosis-700.ttf b/examples/cards/templates/todo-item-live/assets/dosis-700.ttf
new file mode 100644
index 0000000..f3326c6
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/dosis-700.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf
new file mode 100644
index 0000000..e85d230
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf
new file mode 100644
index 0000000..660d25f
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf
new file mode 100644
index 0000000..381489f
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/mplus-1m-bold-sub.ttf b/examples/cards/templates/todo-item-live/assets/mplus-1m-bold-sub.ttf
new file mode 100644
index 0000000..d75297f
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/mplus-1m-bold-sub.ttf differ
diff --git a/examples/cards/templates/todo-item-live/assets/mplus-1m-regular-sub.ttf b/examples/cards/templates/todo-item-live/assets/mplus-1m-regular-sub.ttf
new file mode 100644
index 0000000..110a990
Binary files /dev/null and b/examples/cards/templates/todo-item-live/assets/mplus-1m-regular-sub.ttf differ
diff --git a/examples/cards/templates/todo-item-live/manifest.json b/examples/cards/templates/todo-item-live/manifest.json
index f92c97e..0e5b62d 100644
--- a/examples/cards/templates/todo-item-live/manifest.json
+++ b/examples/cards/templates/todo-item-live/manifest.json
@@ -1,23 +1,29 @@
{
"key": "todo-item-live",
- "title": "Todo Item",
- "notes": "Source-generated card for a single Home Assistant todo item. Do not use a live fetch URL. A card source script writes one card instance per todo uid and fills template_state with the current item fields plus source_id for refresh. The card completes the task by calling the Home Assistant HassListCompleteItem MCP tool with list_name and the task summary.",
+ "title": "Task Item",
+ "notes": "File-backed kanban task card. The template_state is written from ~/.nanobot/workspace/tasks via scripts/task_cards.py. The card shows one primary action plus a compact move menu and persists lane transitions by calling the exec tool to run scripts/task_cards.py move.",
"example_state": {
- "source_id": "ha-todo-kacpers-to-do",
- "entity_id": "todo.kacpers_to_do",
- "list_name": "Kacper's To-Do",
- "uid": "55be123e-1ef3-11f1-b5e6-001e06480aef",
- "summary": "Get sneakers",
- "complete_tool_name": "mcp_home_assistant_HassListCompleteItem",
- "complete_item": "Get sneakers",
- "status": "needs_action",
- "completed": false,
+ "kind": "file_task",
+ "task_path": "/home/kacper/.nanobot/workspace/tasks/backlog/20260316-pack-for-japan.md",
+ "task_key": "b9b79f12-1ef3-11f1-b5e6-001e06480aef",
+ "title": "Pack for Japan",
+ "lane": "backlog",
+ "created": "2026-03-16T18:10:00-04:00",
+ "updated": "2026-03-16T18:10:00-04:00",
"due": null,
- "due_datetime": null,
- "description": null,
- "generated_at": "2026-03-13T16:03:26+00:00",
- "can_complete": true
+ "tags": [
+ "home-assistant",
+ "imported"
+ ],
+ "body": "## Imported\n\n- Source: Home Assistant\n- List: Kacper's To-Do\n- UID: b9b79f12-1ef3-11f1-b5e6-001e06480aef",
+ "metadata": {
+ "source": "home_assistant",
+ "source_entity_id": "todo.kacpers_to_do",
+ "source_list": "Kacper's To-Do",
+ "source_uid": "b9b79f12-1ef3-11f1-b5e6-001e06480aef",
+ "imported_at": "2026-03-16T22:10:00+00:00"
+ }
},
"created_at": "2026-03-13T00:00:00+00:00",
- "updated_at": "2026-03-13T00:00:00+00:00"
+ "updated_at": "2026-03-16T00:00:00+00:00"
}
diff --git a/examples/cards/templates/todo-item-live/template.html b/examples/cards/templates/todo-item-live/template.html
index cb4fb99..2859dcd 100644
--- a/examples/cards/templates/todo-item-live/template.html
+++ b/examples/cards/templates/todo-item-live/template.html
@@ -1,19 +1,260 @@
-
-
-
-
Todo
-
Loading...
+
+
+
+
-
-
+
Loading...
+
+
-
-
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/CardFeed.tsx b/frontend/src/components/CardFeed.tsx
index 3520de9..adaa9c6 100644
--- a/frontend/src/components/CardFeed.tsx
+++ b/frontend/src/components/CardFeed.tsx
@@ -8,6 +8,7 @@ const EXECUTABLE_SCRIPT_TYPES = new Set([
"application/javascript",
"module",
]);
+const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
const cardLiveContentStore = new Map
();
const cardRefreshHandlers = new Map void>();
@@ -74,6 +75,10 @@ function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLEl
return target.closest("[data-nanobot-card-root]");
}
+function dispatchCardLiveContentChange(cardId: string): void {
+ window.dispatchEvent(new CustomEvent(CARD_LIVE_CONTENT_EVENT, { detail: { cardId } }));
+}
+
function setCardLiveContent(
target: HTMLScriptElement | HTMLElement | null,
snapshot: JsonValue | null | undefined,
@@ -84,9 +89,11 @@ function setCardLiveContent(
const cloned = cloneJsonValue(snapshot ?? undefined);
if (cloned === undefined) {
cardLiveContentStore.delete(cardId);
+ dispatchCardLiveContentChange(cardId);
return;
}
cardLiveContentStore.set(cardId, cloned);
+ dispatchCardLiveContentChange(cardId);
}
function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
@@ -437,6 +444,22 @@ const LANE_TITLES: Record = {
history: "History",
};
const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"];
+const LANE_RANK: Record = {
+ attention: 0,
+ work: 1,
+ context: 2,
+ history: 3,
+};
+
+function readCardScore(card: CardItem): number {
+ if (!card.serverId) return card.priority;
+ const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
+ if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
+ return card.priority;
+ }
+ const score = (liveContent as Record).score;
+ return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
+}
interface CardProps {
card: CardItem;
@@ -690,7 +713,13 @@ export function CardFeed({ cards, viewActive, onDismiss, onChoice, onAskCard }:
lane,
title: LANE_TITLES[lane],
cards: cards.filter((card) => card.lane === lane),
- })).filter((group) => group.cards.length > 0),
+ orderScore: Math.max(...cards.filter((card) => card.lane === lane).map(readCardScore)),
+ }))
+ .filter((group) => group.cards.length > 0)
+ .sort((left, right) => {
+ if (left.orderScore !== right.orderScore) return right.orderScore - left.orderScore;
+ return LANE_RANK[left.lane] - LANE_RANK[right.lane];
+ }),
[cards],
);
diff --git a/frontend/src/hooks/useWebRTC.ts b/frontend/src/hooks/useWebRTC.ts
index ba64922..e4f4836 100644
--- a/frontend/src/hooks/useWebRTC.ts
+++ b/frontend/src/hooks/useWebRTC.ts
@@ -13,6 +13,7 @@ import type {
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
+const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
let cardIdCounter = 0;
let logIdCounter = 0;
@@ -80,11 +81,23 @@ interface RTCCallbacks {
closePC: () => void;
}
+function readCardScore(card: Pick): number {
+ if (!card.serverId) return card.priority;
+ const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId);
+ if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) {
+ return card.priority;
+ }
+ const score = (liveContent as Record).score;
+ return typeof score === "number" && Number.isFinite(score) ? score : card.priority;
+}
+
function compareCards(a: CardItem, b: CardItem): number {
- const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
- if (laneDiff !== 0) return laneDiff;
const stateDiff = STATE_RANK[a.state] - STATE_RANK[b.state];
if (stateDiff !== 0) return stateDiff;
+ const scoreDiff = readCardScore(b) - readCardScore(a);
+ if (scoreDiff !== 0) return scoreDiff;
+ const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
+ if (laneDiff !== 0) return laneDiff;
if (a.priority !== b.priority) return b.priority - a.priority;
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
if (updatedDiff !== 0) return updatedDiff;
@@ -182,7 +195,7 @@ function handleTypedMessage(
): void {
if (msg.type === "agent_state") {
idleFallback.clear();
- setAgentState((prev) => (prev === "listening" ? prev : msg.state));
+ setAgentState(() => msg.state);
return;
}
if (msg.type === "message") {
@@ -519,6 +532,26 @@ function useCardsState() {
}
}, []);
+ useEffect(() => {
+ const onCardLiveContentChange = (event: Event) => {
+ const customEvent = event as CustomEvent<{ cardId?: unknown }>;
+ const cardId =
+ customEvent.detail && typeof customEvent.detail.cardId === "string"
+ ? customEvent.detail.cardId
+ : "";
+ setCards((prev) => {
+ if (prev.length === 0) return prev;
+ if (cardId && !prev.some((card) => card.serverId === cardId)) return prev;
+ return sortCards([...prev]);
+ });
+ };
+
+ window.addEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
+ return () => {
+ window.removeEventListener(CARD_LIVE_CONTENT_EVENT, onCardLiveContentChange);
+ };
+ }, []);
+
return { cards, upsertCard, dismissCard, loadPersistedCards };
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 15fcd9c..e252fce 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -638,7 +638,8 @@ body {
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(255, 200, 140, 0.25) transparent;
- background: #ffffff;
+ background: #ece8e1;
+ box-shadow: inset 0 1px 0 rgba(52, 40, 31, 0.24);
}
#card-feed::-webkit-scrollbar {
width: 4px;
@@ -688,7 +689,7 @@ body {
background: transparent;
border: none;
border-radius: 0;
- padding: 0 8px;
+ padding: 0;
box-shadow: none;
}
.card.dismissing {
@@ -878,12 +879,9 @@ body {
color: inherit;
}
.card.kind-text .card-body > [data-nanobot-card-root] > :not(script) {
- border-radius: 16px;
+ border-radius: 0;
overflow: hidden;
- box-shadow:
- 0 6px 14px rgba(0, 0, 0, 0.1),
- 0 14px 26px rgba(0, 0, 0, 0.25),
- inset 0 1px 0 rgba(255, 255, 255, 0.25);
+ box-shadow: none;
}
.card-question {
color: rgba(255, 245, 235, 0.95);