diff --git a/apps/ambient-inventory-agent/app/agent.py b/apps/ambient-inventory-agent/app/agent.py
index aba75a1..5e51da4 100644
--- a/apps/ambient-inventory-agent/app/agent.py
+++ b/apps/ambient-inventory-agent/app/agent.py
@@ -243,7 +243,9 @@ def _model_facing_schema(args_schema: Any) -> dict[str, Any]:
This is the tool's SIGNATURE — which arguments `find` takes. Nothing to do with
document shape: the agent discovers that itself via `collection-schema`.
"""
- properties = args_schema.get("properties") if isinstance(args_schema, dict) else None
+ properties = (
+ args_schema.get("properties") if isinstance(args_schema, dict) else None
+ )
if not isinstance(properties, dict):
return {"type": "object", "properties": {}}
@@ -518,9 +520,7 @@ def stream_messages(chunk: Any) -> list[Any]:
]
-def new_tool_calls(
- msg: Any, seen: set[str]
-) -> list[tuple[str, dict[str, Any], str]]:
+def new_tool_calls(msg: Any, seen: set[str]) -> list[tuple[str, dict[str, Any], str]]:
"""(name, args, rendered command) for each tool call not yet reported.
A streamed message is re-delivered as later chunks arrive, so `seen` — the call
diff --git a/apps/ambient-inventory-agent/app/investigator.py b/apps/ambient-inventory-agent/app/investigator.py
index ea6bdae..ec26ca9 100644
--- a/apps/ambient-inventory-agent/app/investigator.py
+++ b/apps/ambient-inventory-agent/app/investigator.py
@@ -214,8 +214,6 @@ def _readable_thought(line: str) -> str:
return line if len(line) <= 400 else f"{line[:397]}…"
-
-
INVESTIGATOR_PROMPT = """\
You are the inventory monitor for Leafy Roasters, a coffee roaster, running on a \
schedule against the `{database}` MongoDB database. Find the component that has \
@@ -319,7 +317,10 @@ async def file_alert(**fields: Any) -> str:
document = self.repository.build_alert_document(
self._session_id, self._sweep_id, fields
)
- payload = {"collection": "alerts", "documents": [_as_extended_json(document)]}
+ payload = {
+ "collection": "alerts",
+ "documents": [_as_extended_json(document)],
+ }
result = await insert.ainvoke(payload)
if "E11000" in str(result) or "duplicate key" in str(result).lower():
return "An alert for this component already exists; not filing again."
diff --git a/apps/ambient-inventory-agent/app/main.py b/apps/ambient-inventory-agent/app/main.py
index 58a200c..4759836 100644
--- a/apps/ambient-inventory-agent/app/main.py
+++ b/apps/ambient-inventory-agent/app/main.py
@@ -16,8 +16,8 @@
from .db import get_database
from .demo_data import ensure_indexes, ensure_validators, seed_demo_data
from .mcp_session import MCPUnavailable, get_mcp_session
-from .monitor import InventoryMonitor
from .memory import close_checkpointer
+from .monitor import InventoryMonitor
from .repository import InventoryRepository
STATIC_DIR = Path(__file__).parent / "static"
diff --git a/apps/ambient-inventory-agent/app/static/app.js b/apps/ambient-inventory-agent/app/static/app.js
index b3b1e36..9e89b70 100644
--- a/apps/ambient-inventory-agent/app/static/app.js
+++ b/apps/ambient-inventory-agent/app/static/app.js
@@ -29,6 +29,7 @@ const els = {
pageTitle: document.getElementById("pageTitle"),
alertBadge: document.getElementById("alertBadge"),
navItems: Array.from(document.querySelectorAll(".nav-item")),
+ themeToggle: document.getElementById("themeToggle"),
};
// The play control calls /api/demo/start, which re-mints the service-account token,
@@ -1279,6 +1280,30 @@ els.navItems.forEach((item) => {
});
});
+/* ---------- Theme toggle ---------- */
+// The pre-paint script in index.html already applied the stored choice; this only
+// handles clicks and keeps the icon in sync.
+function paintThemeIcon() {
+ const dark = document.documentElement.getAttribute("data-theme") === "dark";
+ // Show the theme you would switch TO, which is the convention users expect.
+ els.themeToggle.textContent = dark ? "☀" : "☾";
+}
+
+els.themeToggle.addEventListener("click", () => {
+ const root = document.documentElement;
+ const dark = root.getAttribute("data-theme") === "dark";
+ if (dark) root.removeAttribute("data-theme");
+ else root.setAttribute("data-theme", "dark");
+ try {
+ localStorage.setItem("theme", dark ? "light" : "dark");
+ } catch (e) {
+ // Private browsing — the toggle still works for this page load.
+ }
+ paintThemeIcon();
+});
+
+paintThemeIcon();
+
/* ---------- Boot ---------- */
render(true);
startSession().catch((error) => {
diff --git a/apps/ambient-inventory-agent/app/static/index.html b/apps/ambient-inventory-agent/app/static/index.html
index 448ac6e..5f47ef9 100644
--- a/apps/ambient-inventory-agent/app/static/index.html
+++ b/apps/ambient-inventory-agent/app/static/index.html
@@ -4,6 +4,19 @@
Leafy Roasters Inventory Assistant
+
@@ -48,9 +61,13 @@
Dashboard
-
- DW
+
+
+
+ DW
+
diff --git a/apps/ambient-inventory-agent/app/static/styles.css b/apps/ambient-inventory-agent/app/static/styles.css
index 2692da0..6660547 100644
--- a/apps/ambient-inventory-agent/app/static/styles.css
+++ b/apps/ambient-inventory-agent/app/static/styles.css
@@ -17,8 +17,13 @@
--border-strong: #d3dade;
--ink: #001e2b;
- --muted: #5c6c75;
- --subtle: #889397;
+ /* Three-level text hierarchy, each clearing WCAG AA (4.5:1) on the LIGHTEST
+ surface it lands on — which is --bg, not white. --subtle was #889397 (2.91:1
+ on --bg), so table headers and card sub-labels failed even the 3:1 large-text
+ bar. --muted darkened alongside it so the three levels stay visually distinct
+ instead of collapsing into two. */
+ --muted: #4f5f68;
+ --subtle: #697174;
--accent: var(--mongodb-green);
--accent-strong: var(--mongodb-forest);
@@ -31,6 +36,38 @@
--success: var(--mongodb-forest);
--success-soft: #e3fcef;
+ /* Text that sits ON a filled accent/danger surface rather than on the page. */
+ --on-solid: #ffffff;
+ /* Ink on the bright-green accent FILL. Distinct from --on-solid because that
+ is white for danger/forest fills, and white on #00ed64 fails AA. */
+ --on-accent-fill: var(--mongodb-slate);
+ --accent-hover: #00d557;
+ --accent-border: #bff3d8;
+
+ --danger-ink: #8c1d18;
+ --danger-ink-strong: #b3261e;
+ --danger-border: #f5c6c2;
+ /* Bare RGB triples: these feed rgba() in the badge pulse and focus rings, where
+ a hex token cannot be interpolated. */
+ --danger-rgb: 208, 39, 29;
+ --focus-blue-rgb: 1, 107, 248;
+ --focus-green-rgb: 0, 237, 100;
+
+ /* Event-tag and pill pairs. Each is a soft background plus an ink chosen to be
+ legible on it, so the two must move together when the theme flips. */
+ --info-soft: #e7f0fe;
+ /* A shade darker than brand blue: #016bf8 only reaches 4.1:1 on --info-soft.
+ The focus ring keeps brand blue, since a border is non-text UI (3:1 bar). */
+ --info-ink: #0d5ed9;
+ --focus-ring: var(--mongodb-blue);
+ --plan-soft: #f3edff;
+ --plan-ink: #6d43c9;
+ --plan-border: #e3d9f7;
+ --db-soft: #eef4ff;
+ --db-ink: #3b62b5;
+ --neutral-soft: #eaf0f2;
+ --code-bg: #f7faf9;
+
--shadow-sm: 0 1px 2px rgba(0, 30, 43, 0.06);
--shadow: 0 1px 3px rgba(0, 30, 43, 0.08), 0 1px 2px rgba(0, 30, 43, 0.04);
--shadow-md: 0 4px 16px rgba(0, 30, 43, 0.08);
@@ -43,6 +80,68 @@
font-family: "Source Sans 3", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
+/* ---------- Dark theme ----------
+ Driven by [data-theme="dark"] rather than prefers-color-scheme alone so the
+ in-app toggle can override the OS: a presenter's laptop is often set to light
+ while the room's projector reads better dark, or the reverse.
+
+ Only tokens are redefined here — no rule below is theme-aware, which is why the
+ previously-hardcoded colours had to become tokens first.
+
+ The soft/ink pairs are NOT mechanical inversions. Each light tint (#e7f0fe)
+ becomes a deep wash and its ink lightens to stay legible on it; every pair is
+ contrast-checked against its own background rather than eyeballed. */
+[data-theme="dark"] {
+ --bg: #000e15;
+ --surface: #001521;
+ --surface-subtle: #04202e;
+ --border: #1d3341;
+ --border-strong: #2a4553;
+
+ --ink: #e8edf0;
+ --muted: #a3b2ba;
+ --subtle: #8798a1;
+
+ /* Forest green is unreadable on a dark surface, so accent-strong brightens
+ rather than darkening. */
+ --accent: var(--mongodb-green);
+ --accent-strong: #4ade80;
+ --accent-soft: #06301f;
+ --accent-border: #10643f;
+ --accent-hover: #00ed64;
+ /* Filled accent surfaces flip to dark text — white on bright green fails AA. */
+ --on-solid: #001e2b;
+
+ --danger: #ff5247;
+ --danger-soft: #2c100e;
+ --danger-ink: #ff9d95;
+ --danger-ink-strong: #ff8078;
+ --danger-border: #5c221e;
+ --danger-rgb: 255, 82, 71;
+
+ --warning: #fbbf24;
+ --warning-soft: #2e2008;
+ --success: #4ade80;
+ --success-soft: #06301f;
+
+ --info-soft: #0a2039;
+ --info-ink: #7cb2ff;
+ --focus-ring: #4d94ff;
+ --plan-soft: #1e1533;
+ --plan-ink: #c4a6ff;
+ --plan-border: #3a2d5c;
+ --db-soft: #101d33;
+ --db-ink: #96b4f0;
+ --neutral-soft: #10242f;
+ --code-bg: #04202e;
+
+ /* Shadows barely register on dark surfaces; deepen them so cards keep the
+ separation they get for free in light mode. */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.55);
+}
+
* {
box-sizing: border-box;
}
@@ -180,7 +279,7 @@ input {
justify-content: center;
border-radius: 999px;
background: var(--danger);
- color: #ffffff;
+ color: var(--on-solid);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
@@ -193,13 +292,13 @@ input {
@keyframes badge-pulse {
0% {
- box-shadow: 0 0 0 0 rgba(208, 39, 29, 0.5);
+ box-shadow: 0 0 0 0 rgba(var(--danger-rgb), 0.5);
}
70% {
- box-shadow: 0 0 0 8px rgba(208, 39, 29, 0);
+ box-shadow: 0 0 0 8px rgba(var(--danger-rgb), 0);
}
100% {
- box-shadow: 0 0 0 0 rgba(208, 39, 29, 0);
+ box-shadow: 0 0 0 0 rgba(var(--danger-rgb), 0);
}
}
@@ -242,7 +341,7 @@ input {
height: 34px;
border-radius: 50%;
background: var(--accent-strong);
- color: #ffffff;
+ color: var(--on-solid);
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
@@ -252,6 +351,37 @@ input {
user-select: none;
}
+/* Sits left of the avatar in the topbar. Icon-only so it reads as app chrome
+ rather than competing with the page's own controls. */
+.theme-toggle {
+ display: inline-grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--muted);
+ font-size: 15px;
+ line-height: 1;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+
+.theme-toggle:hover {
+ background: var(--surface-subtle);
+ border-color: var(--border-strong);
+ color: var(--ink);
+}
+
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
.view {
padding: 28px 32px 40px;
overflow-y: auto;
@@ -398,8 +528,10 @@ input {
}
.pill.info {
- background: #e7f0fe;
- color: var(--mongodb-blue);
+ background: var(--info-soft);
+ /* --info-ink, not raw --mongodb-blue: brand blue only clears 3.5:1 on the dark
+ wash, so the ink has to lighten with the theme. */
+ color: var(--info-ink);
}
/* ---------- Buttons ---------- */
@@ -428,7 +560,7 @@ input {
border: 1px solid var(--accent);
border-radius: var(--radius-btn);
background: var(--accent);
- color: var(--mongodb-slate);
+ color: var(--on-accent-fill);
font-size: 14px;
font-weight: 600;
cursor: pointer;
@@ -436,8 +568,8 @@ input {
}
.btn--primary:hover {
- border-color: #00d557;
- background: #00d557;
+ border-color: var(--accent-hover);
+ background: var(--accent-hover);
}
.btn--primary:disabled {
@@ -642,7 +774,7 @@ input {
}
.risk-tile.critical strong {
- color: #8c1d18;
+ color: var(--danger-ink);
}
.risk-tile.warning {
@@ -711,7 +843,7 @@ input {
.message.owner {
justify-self: end;
background: var(--accent-soft);
- border: 1px solid #bff3d8;
+ border: 1px solid var(--accent-border);
border-bottom-right-radius: 3px;
color: var(--accent-strong);
}
@@ -740,7 +872,7 @@ input {
}
.stream-error {
- color: #b3261e;
+ color: var(--danger-ink-strong);
}
.risk-note {
@@ -762,9 +894,9 @@ input {
}
.banner.danger {
- background: #fdecea;
- border: 1px solid #f5c6c2;
- color: #8c1d18;
+ background: var(--danger-soft);
+ border: 1px solid var(--danger-border);
+ color: var(--danger-ink);
}
.quick-prompts {
@@ -805,21 +937,23 @@ input {
}
.chat-form input:focus {
- border-color: var(--mongodb-blue);
- box-shadow: 0 0 0 3px rgba(1, 107, 248, 0.12);
+ /* Tokenised so the ring tracks the theme. Brand blue passes the 3:1 bar for
+ non-text UI on dark, but only just — --focus-ring brightens it there. */
+ border-color: var(--focus-ring);
+ box-shadow: 0 0 0 3px rgba(var(--focus-blue-rgb), 0.12);
}
.chat-form .btn {
border-color: var(--accent);
background: var(--accent);
- color: var(--mongodb-slate);
+ color: var(--on-accent-fill);
font-weight: 600;
}
.chat-form .btn:hover {
background: var(--accent);
border-radius: 40px;
- box-shadow: 0 0 0 3px rgba(0, 237, 100, 0.22);
+ box-shadow: 0 0 0 3px rgba(var(--focus-green-rgb), 0.22);
}
.note {
@@ -868,13 +1002,13 @@ input {
}
.event-tag.db {
- background: #eef4ff;
- color: #3b62b5;
+ background: var(--db-soft);
+ color: var(--db-ink);
}
.event-tag.plan {
- background: #f3edff;
- color: #6d43c9;
+ background: var(--plan-soft);
+ color: var(--plan-ink);
}
/* Reserved for real Remote MCP tool calls made by the agent. */
@@ -884,13 +1018,13 @@ input {
}
.event-tag.response {
- background: #eaf0f2;
+ background: var(--neutral-soft);
color: var(--ink);
}
.event-tag.danger {
- background: #fdecea;
- color: #b3261e;
+ background: var(--danger-soft);
+ color: var(--danger-ink-strong);
}
.event-time {
@@ -915,7 +1049,7 @@ input {
flex-direction: column;
gap: 4px;
padding-left: 10px;
- border-left: 2px solid #e3d9f7;
+ border-left: 2px solid var(--plan-border);
}
.thinking-line {
@@ -931,7 +1065,7 @@ input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
- background: #f7faf9;
+ background: var(--code-bg);
color: var(--accent-strong);
font-family: "Source Code Pro", monospace;
font-size: 11.5px;
diff --git a/apps/remote-mcp-perf-triage/checkout_app.py b/apps/remote-mcp-perf-triage/checkout_app.py
index f678759..358dd46 100644
--- a/apps/remote-mcp-perf-triage/checkout_app.py
+++ b/apps/remote-mcp-perf-triage/checkout_app.py
@@ -288,15 +288,55 @@ async def index():
Leafy Electronics — Checkout
+
@@ -356,6 +404,8 @@ async def index():
Leafy Electronics
Checkout
+
@@ -380,6 +430,26 @@ async def index():