From 56a198d072da2af438b2df3e9e0650fff14f2b3a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 16 Sep 2026 20:30:22 +0000 Subject: [PATCH 01/33] The empty lock-screen panels carry Jay's demo content The view row shipped seven tabs and only three had anything behind them. Phone, mailbox and apps were empty; settings is gone and projects took its place; pending decisions now ride at the top of alerts where they can be answered without opening a tab of their own. Content is Jay's, specified from the glass: missed calls from the dialer, WhatsApp Business and an agent's Twilio line plus a voicemail; a unified BlackBerry-Hub-style mailbox mixing email, SMS, X DMs and LinkedIn; Instagram, Reddit, a bank and YouTube; projects with progress, agent counts and a blocked flag. Tab order is his too: agents, projects, alerts, mailbox, phone, stats, then apps. Two constraints shaped the implementation. The screen renders BEFORE sign-in, so every line is scripted, server-side and gated behind TAOS_LOCK_DEMO_PANELS on top of the master demo flag. There is no code path from any of it to a real account, and the tests assert the absence of that path rather than only the presence of the content. Swapping settings for projects removed a pre-auth ACTION surface ("stop all agents", reachable by anyone holding the phone) rather than relocating it. Every panel is a poller, and the last bug here was every poller that wipes its container and rebuilds. All five are reconciled by key from the first line, so a row that persists across a repaint keeps its node and cannot replay its 520ms entrance. The tests assert node identity, not rendered values: the broken code always rebuilt the list correctly, which was the whole problem. Three mutations were run against the suite. Dropping the decisions from paintNotifications' placeInOrder, and ignoring the blocked flag, were both caught by exactly one test each. Deleting the re-attach in paintDecisions was NOT caught -- all 63 assertions started with the container already parented, so the repair path was never driven. The sequence that reaches it (no decisions -> a notification poll drops the empty container -> a decision arrives) is now a test, and it fails under that mutation. Also: the battery percentage moves 3px left, and the shared DOM stand-in gains getElementById, which paintNotifications now needs. Docs-Reviewed: /auth/lock-panels is console-only (_request_is_console) and is not reachable with an agent token, so the agent-facing API surface in docs/agent-coordination.md is unchanged. It joins EXEMPT_PATHS for the same reason /auth/lock-stats and /auth/lock-notifications are there: the lock screen fetches it before sign-in. User-visible behaviour is covered by changelog.d/3106-lock-demo-panels.md. --- changelog.d/3106-lock-demo-panels.md | 8 + tests/test_lock_demo_panels.py | 927 +++++++++++++++++++++++++++ tests/test_lock_screen_repaint.py | 16 +- tinyagentos/auth_middleware.py | 2 +- tinyagentos/routes/auth.py | 903 +++++++++++++++++++++++++- 5 files changed, 1838 insertions(+), 18 deletions(-) create mode 100644 changelog.d/3106-lock-demo-panels.md create mode 100644 tests/test_lock_demo_panels.py diff --git a/changelog.d/3106-lock-demo-panels.md b/changelog.d/3106-lock-demo-panels.md new file mode 100644 index 000000000..746f70228 --- /dev/null +++ b/changelog.d/3106-lock-demo-panels.md @@ -0,0 +1,8 @@ +- Lock screen: the Phone, Mailbox, Apps and Projects panels now carry content, + and pending decisions appear at the top of Alerts where they can be approved + or dismissed without leaving the screen. +- Lock screen: the Settings tab is replaced by Projects. +- Lock screen: panel content is scripted demo data served by + `/auth/lock-panels`, which is console-only and 404s unless both + `TAOS_LOCK_DEMO_AGENTS` and `TAOS_LOCK_DEMO_PANELS` are set. The lock screen + renders before sign-in, so there is no path from any of it to a real account. diff --git a/tests/test_lock_demo_panels.py b/tests/test_lock_demo_panels.py new file mode 100644 index 000000000..f10f8c9d2 --- /dev/null +++ b/tests/test_lock_demo_panels.py @@ -0,0 +1,927 @@ +"""The five scripted lock-screen panels: phone, mailbox, apps, projects, decisions. + +Jay, from the glass: "we need demo data for the other lock screen categories +too" -- the view row shipped seven tabs and only three of them had anything +behind them. He then specified the content of every one: missed calls from the +dialer, WhatsApp Business and an agent's Twilio line plus a voicemail; a unified +BlackBerry-Hub-style mailbox mixing email, SMS, X DMs and LinkedIn; Instagram, +Reddit, a bank and YouTube; and pending approvals. He then, from the glass, +swapped the settings tab for PROJECTS ("makes sense as its a projects focused +os"), moved decisions out of a tab of their own and into the top of ALERTS +("thats were decisions will go for quick answering"), and fixed the tab order. + +Two properties matter here and they pull in opposite directions. + +**The screen renders BEFORE sign-in.** Anyone who picks the phone up sees it. +So every line of this content is scripted and server-side, gated behind a demo +flag, and there is no code path from any of it to a real account. A mailbox +panel wired to the user's actual inbox would be a pre-auth leak, not a feature, +and Jay's "email, SMS, X DMs, LinkedIn" list is exactly the shape that invites +that mistake. The tests below assert the absence of that path, not just the +presence of the content. + +**Every panel is a poller, and the last bug on this screen was every poller +that wipes its container and rebuilds.** That is BUG 2b: `paintActivity()` +emptied `#ls-agents` on each 15s tick, so every island was a new node and +replayed its 520ms entrance animation. Five more panels built the same way +would have been the same bug five more times. They are reconciled by key from +the first line instead, and -- exactly as in `test_lock_screen_repaint.py` -- +**these tests assert on NODE IDENTITY, not on rendered values.** "The names are +still right" passes on the broken code too; the broken code always rebuilt the +list correctly, that was the whole problem. + +`test_the_harness_observes_the_defect` puts the wipe back and requires identity +to break. Without that control, a painter that quietly did nothing would report +every property below as satisfied. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess + +import pytest + +import tinyagentos.routes.auth as auth +from tinyagentos.auth_middleware import EXEMPT_PATHS +from tinyagentos.routes.auth import _LOCK_SCREEN_SCRIPT as LOCK_SCRIPT + +from test_lock_screen_gestures import _function +from test_lock_screen_repaint import _DOM, _var + + +# ---------------------------------------------------------------- the server + + +class TestTheContentIsScriptedAndGated: + """The pre-sign-in constraint, asserted rather than commented.""" + + def test_the_master_flag_alone_does_not_turn_the_panels_on(self, monkeypatch): + """Two flags, like the notification stacks. + + The point of the second flag is that the master one stays the single + move that takes down EVERYTHING invented on this screen. A device can + run the agent islands -- the part that shows real state -- with none of + the scripted inbox content beside them. + """ + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "a,b") + monkeypatch.delenv("TAOS_LOCK_DEMO_PANELS", raising=False) + assert auth._demo_panels_enabled() is False + + def test_the_panel_flag_alone_does_not_turn_the_panels_on(self, monkeypatch): + """And the second flag cannot REPLACE the master one. + + Were this to pass, switching off TAOS_LOCK_DEMO_AGENTS would leave a + phone showing invented mail while believing it was in its real state. + """ + monkeypatch.delenv("TAOS_LOCK_DEMO_AGENTS", raising=False) + monkeypatch.setenv("TAOS_LOCK_DEMO_PANELS", "1") + assert auth._demo_panels_enabled() is False + + def test_both_flags_together_turn_them_on(self, monkeypatch): + """The positive control. Without it the two tests above are also what a + function that returned False unconditionally would produce.""" + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "a,b") + monkeypatch.setenv("TAOS_LOCK_DEMO_PANELS", "1") + assert auth._demo_panels_enabled() is True + + def test_the_route_is_exempt_from_auth(self): + """The lock screen fetches this BEFORE sign-in, so it must be exempt. + + `/auth/lock-stats` shipped in #3103 without this and would have 401'd on + the glass -- the stats panel was empty for exactly that reason. One line + of registration, one whole panel, no error anywhere. + """ + assert "/auth/lock-panels" in EXEMPT_PATHS + + def test_every_item_in_every_panel_is_marked_demo(self): + """Marked at construction, so nothing downstream has to work out that + these are placeholders by elimination.""" + panels = auth._demo_panels() + assert set(panels) == {"phone", "mailbox", "apps", "projects", "decisions"} + for name, items in panels.items(): + assert items, f"{name} is empty" + for item in items: + assert item["demo"] is True, (name, item) + + def test_no_panel_item_carries_a_route_to_a_real_account(self): + """The pre-auth constraint, as a property of the payload. + + A URL, an address, an account id or a message id is the shape a real + inbox leaks through: it is what a "helpful" later change would add to + make a row openable. There is nothing to open. This asserts the SHAPE of + what is served rather than a list of bad values, because the values a + leak would carry are exactly the ones nobody thought to enumerate. + """ + allowed = { + "key", "demo", "at", "app", "who", "detail", "kind", "glyph", + "mono", "tint", "source", "subject", "preview", "unread", + "badge", "note", "title", "agent", + "name", "progress", "agents", "blocked", + } + for name, items in auth._demo_panels().items(): + for item in items: + extra = set(item) - allowed + assert not extra, f"{name} item {item['key']} carries {extra}" + + def test_the_scripted_tables_are_the_only_source(self): + """`_demo_panels()` reads module constants and the clock. Nothing else. + + If this ever needs relaxing, that is the moment to re-read the rule at + the top of this file: the change that breaks it is the change that wires + a pre-sign-in screen to a real inbox. + """ + import inspect + + src = inspect.getsource(auth._demo_panels) + for forbidden in ("request", "await", "open(", "fetch", "session", "db", "sql"): + assert forbidden not in src.lower(), forbidden + + @pytest.mark.parametrize("panel", ["phone", "mailbox", "apps", "projects", "decisions"]) + def test_every_key_within_a_panel_is_unique(self, panel): + """Keys are what the client reconciles on. Two rows sharing one would + make the second permanently overwrite the first -- and it would look + like a content bug, not a keying bug.""" + keys = [item["key"] for item in auth._demo_panels()[panel]] + assert len(keys) == len(set(keys)), keys + + +class TestJaysContent: + """Jay specified these by name. A panel that renders beautifully without the + thing he asked for is still not the thing he asked for.""" + + def test_the_phone_panel_names_every_source_jay_asked_for(self): + """Dialer, WhatsApp Business, an agent's Twilio line, and a voicemail.""" + items = auth._demo_panels()["phone"] + apps = {item["app"] for item in items} + assert "Phone" in apps, apps + assert "WA+" in apps, apps + assert "Twilio" in apps, apps + assert any(item["kind"] == "voicemail" for item in items), items + assert any(item["kind"] == "missed" for item in items), items + + def test_the_twilio_call_is_an_agents_line(self): + """The one entry that is about the product rather than the person: an + agent holds a number and something rang it. That is the demo's point, + and a generic missed call in its place would lose it.""" + twilio = [i for i in auth._demo_panels()["phone"] if i["app"] == "Twilio"] + assert twilio, "the agent's Twilio line is gone" + assert "agent" in twilio[0]["who"].lower(), twilio[0] + + def test_no_demo_number_can_reach_a_real_subscriber(self): + """Ofcom reserves 07700 900xxx for drama. A plausible-looking number + that is not in that range is somebody's actual phone.""" + import re + + for item in auth._demo_panels()["phone"]: + for number in re.findall(r"0\d[\d ]{7,}", item.get("detail", "")): + digits = number.replace(" ", "") + assert digits.startswith("07700900"), (item["key"], number) + + def test_the_mailbox_is_unified_not_email_only(self): + """Jay: "like blackberry's unified messaging system" -- mail AND SMS AND + X DMs AND LinkedIn in ONE stream. An email-only list with a nice header + is the thing he specifically did not ask for.""" + sources = {item["source"] for item in auth._demo_panels()["mailbox"]} + assert sources == {"mail", "sms", "x", "linkedin"}, sources + + def test_the_mailbox_is_one_stream_ordered_by_arrival(self): + """Not grouped by source. A unified inbox that sorts into four blocks is + four inboxes on one screen.""" + items = auth._demo_panels()["mailbox"] + ats = [item["at"] for item in items] + assert ats == sorted(ats, reverse=True), ats + # And the order genuinely interleaves -- a table that happened to be + # authored source-by-source would satisfy the sort and fail the point. + runs = [item["source"] for item in items] + assert len(set(runs[:3])) == 3, runs + + def test_every_message_says_where_it_came_from(self): + """The per-item source is the design, not decoration: a unified list + that does not name each line's origin is just a worse inbox.""" + for item in auth._demo_panels()["mailbox"]: + assert item["app"], item + + def test_the_apps_panel_is_the_four_apps_jay_named(self): + apps = {item["app"] for item in auth._demo_panels()["apps"]} + assert apps == {"Instagram", "Reddit", "Bank", "YouTube"}, apps + + def test_the_bank_tile_shows_no_balance(self): + """The one genuinely sensitive-looking line on a pre-auth screen. The + tile says a payment needs a look; it does not say how much is there.""" + bank = [i for i in auth._demo_panels()["apps"] if i["app"] == "Bank"][0] + assert "£" not in bank["note"], bank + assert not any(ch.isdigit() for ch in bank["note"]), bank + + def test_decisions_name_the_agent_that_is_blocked(self): + """These rows are the lock screen's reason to exist: an agent got far + enough to need a human and stopped. Without the agent the panel is a + to-do list.""" + items = auth._demo_panels()["decisions"] + assert items + for item in items: + assert item["agent"], item + assert item["title"] and item["detail"], item + + def test_every_project_says_how_far_along_and_who_is_on_it(self): + """A project is a body of work with agents on it. Without the progress + and the agent count it is a bookmark.""" + items = auth._demo_panels()["projects"] + assert items + for item in items: + assert item["name"] and item["note"], item + assert 0 <= item["progress"] <= 100, item + assert item["agents"] >= 1, item + + def test_blocked_projects_sort_above_everything_else(self): + """Work that has stopped and is waiting on a person is the reason this + panel is on a LOCK screen -- and going quiet is exactly what would sink + it to the bottom of a pure recency sort.""" + items = auth._demo_panels()["projects"] + blocked = [i for i in items if i["blocked"]] + assert blocked, "nothing is blocked, so this proves nothing" + assert all(i["blocked"] for i in items[:len(blocked)]), [ + (i["key"], i["blocked"]) for i in items + ] + # And within the blocked run, still newest-first. + ats = [i["at"] for i in blocked] + assert ats == sorted(ats, reverse=True), ats + + def test_the_projects_panel_carries_no_actions(self): + """It replaced a panel of pre-auth ACTIONS -- "stop all agents", + reachable by anyone holding the phone. Swapping it for read-only content + removed that exposure; an action creeping back in here would restore it + without anyone deciding to. + """ + for item in auth._demo_panels()["projects"]: + assert "kind" not in item, item + assert "action" not in item, item + + def test_the_lock_screen_has_no_settings_tab_at_all(self): + """Jay swapped it for projects. The tab going but the panel staying + would leave the actions on the page, just harder to reach.""" + keys = [key for key, _l, _i, _p in auth._LOCK_VIEWS] + assert "settings" not in keys, keys + html = auth._lock_head_html() + assert "ls-settings" not in html + assert "lv-settings" not in auth._VIEW_SPRITE + + def test_the_tab_order_is_the_one_jay_gave(self): + """"the icon order on lockscreen should be agents, projects, alerts, + mailbox, phone, stats" -- then apps, which he asked to keep but did not + place. Asserted as the whole list, in order: a membership check would + pass on any shuffle of it, and the order IS the ask. + """ + keys = [key for key, _l, _i, _p in auth._LOCK_VIEWS] + assert keys == ["agents", "projects", "alerts", "mailbox", + "phone", "stats", "apps"], keys + + def test_decisions_are_not_a_tab_but_live_inside_alerts(self): + """Jay: "i meant alerts not decisions (but thats were decisions will go + for quick answering)". So the decisions container is a child of the + alerts panel, not a panel of its own -- and if it ever became one, the + answering would move off the screen he put it on. + """ + keys = [key for key, _l, _i, _p in auth._LOCK_VIEWS] + assert "decisions" not in keys, keys + html = auth._lock_head_html() + alerts = html.index('id="ls-notifs"') + decisions = html.index('id="ls-decisions"') + closing = html.index("", alerts) + assert alerts < decisions < closing, "decisions is not inside the alerts panel" + + def test_every_view_with_scripted_content_has_a_panel_in_the_markup(self): + """The failure this catches is a tab that opens onto nothing.""" + html = auth._lock_head_html() + for key in ("phone", "mailbox", "apps", "projects"): + panel = [p for k, _l, _i, p in auth._LOCK_VIEWS if k == key][0] + assert f'id="{panel}"' in html, key + assert f'aria-labelledby="ls-tab-{key}"' in html, key + + +class TestTheRoute: + """The gate, exercised rather than read.""" + + @staticmethod + def _call(monkeypatch, *, console=True, agents="a,b", panels="1"): + import asyncio + + monkeypatch.setattr(auth, "_request_is_console", lambda _r: console) + if agents is None: + monkeypatch.delenv("TAOS_LOCK_DEMO_AGENTS", raising=False) + else: + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", agents) + if panels is None: + monkeypatch.delenv("TAOS_LOCK_DEMO_PANELS", raising=False) + else: + monkeypatch.setenv("TAOS_LOCK_DEMO_PANELS", panels) + return asyncio.run(auth.lock_panels(object())) + + def test_a_non_console_request_is_refused(self, monkeypatch): + """Same rule as every other lock-screen endpoint: this screen is the + device's own glass, and a LAN browser is not it.""" + assert self._call(monkeypatch, console=False).status_code == 403 + + def test_the_demo_flags_off_is_a_404_not_an_empty_payload(self, monkeypatch): + """404 so the client leaves the panels alone and they render their own + "nothing here". An empty payload would be a claim that the user has no + mail, which is a different and wrong statement.""" + assert self._call(monkeypatch, panels=None).status_code == 404 + + def test_the_flags_on_serve_the_panels(self, monkeypatch): + """The positive control for the two refusals above.""" + resp = self._call(monkeypatch) + assert resp.status_code == 200 + body = json.loads(bytes(resp.body)) + assert body["demo"] is True + assert set(body) == {"phone", "mailbox", "apps", "projects", "decisions", "demo"} + assert body["phone"] and body["mailbox"] and body["apps"] + assert body["decisions"] and body["projects"] + + +# ----------------------------------------------------------- the client paint + +#: Listener recording and `closest()`, which the shared stand-in does not carry. +#: The settings switches and the decision buttons are the first things on this +#: screen the user OPERATES rather than reads, so a harness that cannot deliver +#: a click cannot see whether an answer survives the next repaint -- which is +#: the property that matters, since a repaint lands every 15 minutes regardless +#: of what the user is in the middle of. +_EVENTS = r""" +var __realMake = makeNode; +makeNode = function (tag) { + var el = __realMake(tag); + el.__handlers = {}; + // A style store that can be READ BACK. The shared stand-in's setProperty is + // a no-op returning undefined, and paintTile asks what the tint currently is + // before writing it -- against the no-op that is a TypeError, and had it + // merely returned undefined every tile would have been rewritten on every + // paint while the identity assertions still passed. + el.style = { + _p: {}, + setProperty: function (k, v) { this._p[k] = String(v); }, + getPropertyValue: function (k) { + return Object.prototype.hasOwnProperty.call(this._p, k) ? this._p[k] : ""; + } + }; + el.addEventListener = function (t, fn) { + (this.__handlers[t] = this.__handlers[t] || []).push(fn); + }; + el.closest = function (sel) { + var want = sel.replace(/^\./, ""), n = this; + while (n) { + if (String(n.className).split(/\s+/).indexOf(want) !== -1) return n; + n = n.parent; + } + return null; + }; + return el; +}; +// createElement captured the ORIGINAL makeNode when the stand-in was built, so +// reassigning the name alone would have left every element the painters create +// without a listener store -- and every click test silently doing nothing. +document.createElement = makeNode; + +function fire(target, type) { + var n = target; + while (n) { + var hs = n.__handlers && n.__handlers[type]; + if (hs) { + for (var i = 0; i < hs.length; i++) { + hs[i]({ target: target, preventDefault: function () {} }); + } + } + n = n.parent; + } +} + +function findPart(el, key) { + for (var i = 0; i < el.children.length; i++) { + var c = el.children[i]; + if (c.getAttribute("data-part") === key) return c; + var deep = findPart(c, key); + if (deep) return deep; + } + return null; +} +function partText(el, key) { + var n = findPart(el, key); + return n ? n.textContent : null; +} +function findClass(el, cls) { return el.querySelector("." + cls); } +""" + +_PANEL_STATE = r""" +var panelEls = { + phone: makeNode("div"), mailbox: makeNode("div"), apps: makeNode("div"), + projects: makeNode("div"), decisions: makeNode("div") +}; +// The decisions container lives INSIDE the alerts panel on the real page, so +// the harness gives it the same home -- otherwise the attach/detach the +// painter performs would have nothing to attach to and would silently no-op. +var notifsEl = makeNode("div"); +notifsEl.setAttribute("id", "ls-notifs"); +panelEls.decisions.setAttribute("id", "ls-decisions"); +notifsEl.appendChild(panelEls.decisions); + +// State the real notification painter reads. screenEl null means "no sheet is +// open", which is the state a poll normally lands in. +var screenEl = null; +var notifOpen = {}; +var notifClocks = []; + +// A real id lookup rather than a map to the node we happen to want. The roots +// deliberately EXCLUDE the decisions container itself: once a notification +// paint has removed it from the panel it is detached, and a detached node is +// not findable by getElementById in a browser either. A stub that handed it +// back regardless would hide exactly the failure this file is here to catch. +var __roots = [notifsEl, panelEls.phone, panelEls.mailbox, + panelEls.apps, panelEls.projects]; +document.getElementById = function (id) { + for (var i = 0; i < __roots.length; i++) { + if (__roots[i].getAttribute("id") === id) return __roots[i]; + var found = (function walk(n) { + for (var j = 0; j < n.children.length; j++) { + if (n.children[j].getAttribute("id") === id) return n.children[j]; + var deep = walk(n.children[j]); + if (deep) return deep; + } + return null; + })(__roots[i]); + if (found) return found; + } + return null; +}; +var panelClocks = []; +var decAnswered = {}; +var lastPanels = {}; +function syncFeedFade() {} +""" + +#: The defect put back: empty every panel, then paint. The rendered values come +#: out identical -- that is the point, and why identity is the only thing that +#: can tell the two apart. +_WIPE = r""" +var __reconciled = paintPanels; +paintPanels = function (data) { + for (var k in panelEls) { if (panelEls[k]) panelEls[k].textContent = ""; } + return __reconciled(data); +}; +""" + +_DRIVER = r""" +function snapshot() { + var out = {}; + for (var k in panelEls) { + // The apps panel nests its tiles in a grid; everything else is rows + // directly under the panel. + var host = panelEls[k]; + if (k === "apps" && host.children.length + && String(host.children[0].className).indexOf("ls-apps-grid") !== -1) { + host = host.children[0]; + } + out[k] = host.children.map(function (el) { + var actions = findPart(el, "actions"); + return { + id: el.__id, + key: el.getAttribute("data-part"), + cls: el.className, + title: partText(el, "title"), + subject: partText(el, "subject"), + sub: partText(el, "sub"), + app: partText(el, "app"), + when: partText(el, "when"), + mark: (findPart(el, "tile") || {}).textContent, + badge: partText(el, "badge"), + name: partText(el, "name"), + note: partText(el, "note"), + label: partText(el, "label"), + done: partText(el, "done"), + unread: el.getAttribute("data-unread"), + kind: el.getAttribute("data-kind"), + answered: el.getAttribute("data-answered"), + flag: partText(el, "flag"), + pct: (findPart(el, "bar") || { getAttribute: function () { return null; } }) + .getAttribute("aria-valuenow"), + handlers: actions && actions.__handlers.click + ? actions.__handlers.click.length : null + }; + }); + } + out.__clocks = panelClocks.length; + // Where the decisions container actually IS. Painted into a detached node it + // would be correct, complete and invisible. + out.__decisions_parented = panelEls.decisions.parent === notifsEl; + out.__decisions_first = notifsEl.children[0] === panelEls.decisions; + return out; +} + +// Click the first control matching a class inside a named panel row. +function clickIn(panel, rowKey, cls) { + var host = panelEls[panel]; + var row = findPart(host, rowKey); + if (!row) throw new Error("no row " + rowKey + " in " + panel); + var btn = findClass(row, cls); + if (!btn) throw new Error("no ." + cls + " in " + rowKey); + fire(btn, "click"); + return btn; +} + +var SCN = JSON.parse(process.env.LS_PANELS); +var snapshots = []; +for (var t = 0; t < SCN.ticks.length; t++) { + paintPanels(SCN.ticks[t]); + var acts = (SCN.clicks || {})[String(t)] || []; + for (var a = 0; a < acts.length; a++) { + clickIn(acts[a][0], acts[a][1], acts[a][2]); + } + // The two painters run on independent timers. Driving a notification paint + // AFTER the panels is the order that deletes the decisions, so it is the + // order worth driving. + if (SCN.notifyAfter) paintNotifications({ groups: SCN.groups }); + snapshots.push(snapshot()); +} +process.stdout.write(JSON.stringify(snapshots)); +""" + + +def _panel_source(*, reconciled: bool = True) -> str: + """The SHIPPED painters, lifted out of the served script. + + Extracted rather than re-typed for the reason the repaint suite gives: a + copy of the code under test is a test of the copy. + """ + parts = [ + _var("NOTIF_GLYPHS"), + _function("placeInOrder"), + _function("partOf"), + _function("setText"), + _function("setAttrIfChanged"), + _function("whenText"), + _function("paintTile"), + _function("paintRowHead"), + _function("paintEmpty"), + _function("paintPhone"), + _function("paintMailbox"), + _function("paintApps"), + _function("paintDecisions"), + _function("paintProjects"), + _function("paintPanels"), + # The REAL notification painter, because the collision this file tests + # is between two painters that both end in placeInOrder on the same + # container. A stand-in for one of them would be a test of the stand-in. + _function("notifCard"), + _function("notifGroup"), + _function("notifIdentity"), + _function("paintNotifications"), + ] + src = "\n".join(parts) + if not reconciled: + src += _WIPE + return src + + +def _run(ticks, *, clicks=None, reconciled: bool = True, notify_after: bool = False): + node = shutil.which("node") or shutil.which("nodejs") + if not node: + pytest.skip("node is not installed") + script = ( + _DOM + _EVENTS + _PANEL_STATE + + _panel_source(reconciled=reconciled) + + _DRIVER + ) + env = dict(os.environ) + env["LS_PANELS"] = json.dumps({ + "ticks": ticks, + "clicks": clicks or {}, + "notifyAfter": notify_after, + "groups": auth._demo_notifications(), + }) + proc = subprocess.run( + [node, "-e", script], capture_output=True, text=True, env=env, timeout=60 + ) + assert proc.returncode == 0, proc.stderr[-4000:] + return json.loads(proc.stdout) + + +def _payload(**over): + """A payload of the shape the route serves, from the real tables.""" + data = auth._demo_panels() + data.update(over) + return data + + +def _ids(snap, panel): + return [row["id"] for row in snap[panel]] + + +def _keys(snap, panel): + return [row["key"] for row in snap[panel]] + + +PANELS = ["phone", "mailbox", "apps", "projects", "decisions"] + + +class TestThePanelsSurviveTheRepaint: + """Node identity across an unchanged repaint. This is the whole point.""" + + @pytest.mark.parametrize("panel", PANELS) + def test_an_unchanged_payload_keeps_every_row_node(self, panel): + """The property Jay sees as "it does not flicker". + + A row that is the same node across a repaint cannot replay its 520ms + entrance animation, because nothing entered. A row that is a new node + replays it whether or not one byte of the payload changed. + """ + one, two = _run([_payload(), _payload()]) + assert _ids(one, panel) == _ids(two, panel) + assert _ids(one, panel), f"{panel} rendered nothing to compare" + + @pytest.mark.parametrize("panel", PANELS) + def test_an_unchanged_payload_leaves_the_order_alone(self, panel): + one, two = _run([_payload(), _payload()]) + assert _keys(one, panel) == _keys(two, panel) + + def test_a_changed_line_is_written_into_the_SAME_row(self): + """The discriminating case. Rebuilding gets the text right too -- it + always did -- so a text assertion alone proves nothing. This requires + the new text AND the old node.""" + first = _payload() + second = auth._demo_panels() + second["mailbox"][0] = dict(second["mailbox"][0], + preview="changed while you were reading it") + one, two = _run([first, second]) + assert _ids(one, "mailbox") == _ids(two, "mailbox") + assert two["mailbox"][0]["sub"] == "changed while you were reading it" + assert one["mailbox"][0]["sub"] != two["mailbox"][0]["sub"] + + def test_a_new_row_is_added_without_disturbing_the_others(self): + first = _payload() + second = auth._demo_panels() + second["phone"] = [dict(second["phone"][0], key="call-new", + who="Someone New")] + second["phone"] + one, two = _run([first, second]) + assert "call-new" in _keys(two, "phone") + # Every row that was there before is the same node, still in order. + kept = [row for row in two["phone"] if row["key"] != "call-new"] + assert [row["id"] for row in kept] == _ids(one, "phone") + + def test_a_departed_row_is_removed_without_disturbing_the_others(self): + first = _payload() + second = auth._demo_panels() + gone = second["mailbox"].pop(2) + one, two = _run([first, second]) + assert gone["key"] not in _keys(two, "mailbox") + kept = [row for row in one["mailbox"] if row["key"] != gone["key"]] + assert _ids(two, "mailbox") == [row["id"] for row in kept] + + def test_the_minute_labels_are_collected_from_what_is_on_screen(self): + """They are retouched in place on their own timer. Collected from the + DOM rather than from what the paint just built, so a row the paint left + untouched still has its minutes moved on.""" + one, _ = _run([_payload(), _payload()]) + # phone, mailbox, projects and decisions all carry timestamps; the + # apps grid deliberately does not. + panels = auth._demo_panels() + want = (len(panels["phone"]) + len(panels["mailbox"]) + + len(panels["projects"]) + len(panels["decisions"])) + assert one["__clocks"] == want, one["__clocks"] + + def test_the_apps_grid_itself_survives(self): + """The grid is created by the painter, not the markup, so it is one + more thing a wipe would replace under the user.""" + one, two = _run([_payload(), _payload()]) + assert _ids(one, "apps") == _ids(two, "apps") + + +class TestWhatTheUserDidSurvivesAPaint: + """A repaint lands every 15 minutes whatever the user is in the middle of. + + Jay will be holding the phone in front of people. A switch that snaps back, + or an approval that reappears unanswered, is worse than the panel not being + there -- it reads as the device ignoring him. + """ + + def test_an_answered_decision_stays_answered_across_a_repaint(self): + snaps = _run( + [_payload(), _payload()], + clicks={"0": [["decisions", "dec-invoice", "ls-dec-btn"]]}, + ) + first = {r["key"]: r for r in snaps[0]["decisions"]}["dec-invoice"] + assert first["answered"] == "1", "the click did nothing" + assert first["done"] == "Denied (demo)" + after = {r["key"]: r for r in snaps[1]["decisions"]}["dec-invoice"] + assert after["answered"] == "1" + assert after["done"] == "Denied (demo)" + assert after["id"] == first["id"] + + def test_an_approval_is_distinguishable_from_a_refusal(self): + """Both arms. A painter that wrote the same word either way would pass + the test above, which only ever clicks one button.""" + snaps = _run( + [_payload()], + clicks={"0": [["decisions", "dec-invoice", "ls-dec-btn"], + ["decisions", "dec-reply", "ls-dec-approve"]]}, + ) + rows = {r["key"]: r for r in snaps[0]["decisions"]} + assert rows["dec-invoice"]["done"] == "Denied (demo)", rows["dec-invoice"] + assert rows["dec-reply"]["done"] == "Approved (demo)", rows["dec-reply"] + + def test_a_repaint_does_not_stack_a_second_click_handler(self): + """The bug a reconciled list grows quietly: wiring on every paint means + the fourth repaint fires an action four times from one tap. It never + shows up as a rendering fault, which is why it is asserted directly.""" + snaps = _run([_payload(), _payload(), _payload(), _payload()]) + for snap in snaps: + rows = {r["key"]: r for r in snap["decisions"]} + assert rows["dec-invoice"]["handlers"] == 1, rows["dec-invoice"] + + +class TestTheContentReachesTheGlass: + """That the reconciler is faithful, not just stable. A painter that drew + nothing would satisfy every identity assertion above.""" + + def test_the_phone_panel_renders_a_row_per_call(self): + one, = _run([_payload()]) + assert _keys(one, "phone") == [i["key"] for i in auth._demo_panels()["phone"]] + missed = [r for r in one["phone"] if r["kind"] == "missed"] + assert len(missed) == 4, one["phone"] + assert any(r["kind"] == "voicemail" for r in one["phone"]) + + def test_the_mailbox_marks_unread_rows_and_leaves_read_ones_alone(self): + """Both arms. An attribute set on everything is not a mark.""" + one, = _run([_payload()]) + unread = [r["key"] for r in one["mailbox"] if r["unread"] == "1"] + read = [r["key"] for r in one["mailbox"] if r["unread"] is None] + assert unread and read, one["mailbox"] + expected = {i["key"] for i in auth._demo_panels()["mailbox"] if i["unread"]} + assert set(unread) == expected + + def test_each_mailbox_row_shows_its_source_subject_and_preview(self): + one, = _run([_payload()]) + row = {r["key"]: r for r in one["mailbox"]}["dm-x-marcus"] + assert row["app"] == "X" + assert row["title"] == "@marcus_dev" + assert row["subject"] == "Direct message" + assert "4GB board" in row["sub"] + + def test_an_app_tile_keeps_its_badge_beside_its_monogram(self): + """The badge is a SIBLING of the mark, because the painter rewrites the + mark's contents outright -- parented inside it, the badge was wiped on + the first paint of every tile without a glyph, which is all four.""" + one, = _run([_payload()]) + rows = {r["key"]: r for r in one["apps"]} + assert rows["app-reddit"]["badge"] == "12", rows["app-reddit"] + assert rows["app-reddit"]["name"] == "Reddit" + assert rows["app-instagram"]["badge"] == "7" + + def test_an_empty_panel_says_so_rather_than_rendering_blank(self): + """A panel that served nothing and a panel that failed to load must not + look the same to the user.""" + one, = _run([_payload(phone=[], apps=[])]) + assert _keys(one, "phone") == ["empty"], one["phone"] + assert _keys(one, "apps") == ["empty"], one["apps"] + + def test_an_empty_panel_refills_when_content_arrives(self): + """The "nothing here" card is a row like any other, so it has to be + cleared by the reconciler rather than lingering above the content.""" + one, two = _run([_payload(phone=[]), _payload()]) + assert _keys(one, "phone") == ["empty"] + assert "empty" not in _keys(two, "phone") + assert _keys(two, "phone") == [i["key"] for i in auth._demo_panels()["phone"]] + + def test_a_project_row_shows_its_progress_and_flags_the_blocked_ones(self): + """Both arms again: a flag drawn on every row is not a flag.""" + one, = _run([_payload()]) + rows = {r["key"]: r for r in one["projects"]} + assert rows["prj-brightside"]["flag"] == "Blocked", rows["prj-brightside"] + assert rows["prj-taos-site"]["flag"] is None, rows["prj-taos-site"] + assert rows["prj-taos-site"]["pct"] == "88", rows["prj-taos-site"] + assert rows["prj-taos-site"]["app"] == "2 agents" + # One agent is not "1 agents". + assert rows["prj-northlight"]["app"] == "1 agent" + + def test_the_progress_bar_keeps_its_node_so_it_animates_from_where_it_was(self): + """The bar's width is a CSS transition on a node the reconciler keeps. + Rebuild the row and every bar re-runs from 0% on every poll -- the same + flicker as the islands wearing a different costume.""" + first = _payload() + second = auth._demo_panels() + for row in second["projects"]: + if row["key"] == "prj-taos-site": + row["progress"] = 93 + one, two = _run([first, second]) + before = {r["key"]: r for r in one["projects"]}["prj-taos-site"] + after = {r["key"]: r for r in two["projects"]}["prj-taos-site"] + assert before["pct"] == "88" and after["pct"] == "93" + assert before["id"] == after["id"] + + def test_decisions_sit_at_the_top_of_the_alerts_panel(self): + """Not a tab of their own: Jay put them in alerts for quick answering. + Painted into a detached container they would be correct, complete and + invisible -- which is why this asserts the PARENT, not the contents.""" + one, = _run([_payload()]) + assert one["__decisions_parented"] is True + assert one["__decisions_first"] is True + + def test_a_decision_arriving_after_the_container_was_dropped_re_attaches(self): + """The sequence a mutation caught this suite missing. + + With nothing pending, the decisions container empties and steps out of + the alerts panel -- and the next notification paint, finding it empty, + legitimately leaves it out of `want`, so placeInOrder removes it. When + a decision then arrives, paintDecisions is painting into a DETACHED + node: correct, complete and invisible. + + Deleting the re-attach left all 63 assertions green, because every one + of them started with the container already in place. An untested repair + path and a repair path that does nothing are the same reading. + """ + empty = _payload(decisions=[]) + one, two = _run([empty, _payload()], notify_after=True) + assert one["__decisions_parented"] is False, ( + "the container should have been dropped while empty -- " + "this scenario is not reaching the state it means to test" + ) + assert two["__decisions_parented"] is True, ( + "a decision arrived and was painted into a detached container" + ) + assert two["__decisions_first"] is True + assert len(two["decisions"]) == len(auth._demo_panels()["decisions"]) + + def test_a_notification_paint_does_not_delete_the_decisions(self): + """The two run on independent timers and both end in placeInOrder, + which removes everything past the last wanted element. This is the + collision, driven in the order that breaks it.""" + one, = _run([_payload()], notify_after=True) + assert one["__decisions_parented"] is True, ( + "a notification poll dropped the decisions out of the alerts panel" + ) + assert len(one["decisions"]) == len(auth._demo_panels()["decisions"]) + + +class TestTheHarnessCanFail: + """Without these, every assertion above is also what a painter that did + nothing at all would produce.""" + + def test_the_harness_observes_the_defect(self): + """Put the wipe back -- empty each panel, then paint -- and identity + must break in every panel. + + This is the control that makes the whole file mean something. The + mutation leaves every rendered value correct, exactly as the real bug + did: the islands were always rebuilt with the right names. If these + assertions could not tell the two apart they would be measuring + nothing. + """ + one, two = _run([_payload(), _payload()], reconciled=False) + for panel in PANELS: + assert _keys(one, panel) == _keys(two, panel), ( + f"{panel}: the mutation should change identity, not content" + ) + assert _ids(one, panel) != _ids(two, panel), ( + f"{panel}: identity survived a full wipe -- " + "this suite cannot see the defect it exists to catch" + ) + + def test_the_mutation_applied(self): + """A mutation that did not apply is not a green. The wipe is asserted + into the source and read back out, so a renamed painter cannot turn the + control above into a silent no-op.""" + mutated = _panel_source(reconciled=False) + assert "paintPanels = function (data)" in mutated + assert 'panelEls[k].textContent = ""' in mutated + assert "var __reconciled = paintPanels;" in mutated + assert _WIPE not in _panel_source(reconciled=True) + + def test_the_extractor_returns_one_function_each(self): + """`_function` reads to a balanced brace. When an apostrophe in a + comment once opened a string that never closed, it returned 12kB + instead of 5kB -- three functions where one was asked for -- and every + test still passed, because the extra functions were the real ones. + """ + for name, ceiling in ( + ("paintPhone", 3000), + ("paintMailbox", 3000), + ("paintApps", 3000), + ("paintProjects", 6000), + ("paintPanels", 3000), + ): + src = _function(name) + assert src.startswith(f"function {name}("), name + assert src.count(f"function {name}(") == 1, name + assert len(src) < ceiling, (name, len(src)) + + def test_the_driver_would_notice_a_painter_that_drew_nothing(self): + """The positive control for the snapshot itself: with no content in the + payload every panel comes back as its empty card, which is a different + reading from the populated one above.""" + one, = _run([{"phone": [], "mailbox": [], "apps": [], + "projects": [], "decisions": []}]) + # Decisions is the exception BY DESIGN: it is the head of the alerts + # panel, not a panel, so with nothing pending it empties and detaches + # rather than showing a "nothing to decide" card above the stacks. + for panel in ("phone", "mailbox", "apps", "projects"): + assert _keys(one, panel) == ["empty"], (panel, one[panel]) + assert _keys(one, "decisions") == [], one["decisions"] diff --git a/tests/test_lock_screen_repaint.py b/tests/test_lock_screen_repaint.py index 20594d371..34eba0070 100644 --- a/tests/test_lock_screen_repaint.py +++ b/tests/test_lock_screen_repaint.py @@ -68,7 +68,10 @@ def _var(name: str) -> str: _text: "", addEventListener: function () {}, focus: function () {}, - setAttribute: function (k, v) { this._attrs[k] = String(v); }, + setAttribute: function (k, v) { + this._attrs[k] = String(v); + if (k === "id") ID_INDEX[String(v)] = this; + }, getAttribute: function (k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; }, @@ -152,9 +155,20 @@ def _var(name: str) -> str: return el; } +//: Nodes that have been given an id, so document.getElementById can answer. +//: paintNotifications looks up `ls-decisions` -- the pending-decision list that +//: rides at the top of the alerts panel -- and a document without the method at +//: all is a TypeError that reads, from here, as the painter being broken. +//: Nothing in THIS file ever sets that id, so the lookup correctly returns null +//: and the notification stacks are painted exactly as they were before. +var ID_INDEX = {}; + var document = { createElement: makeNode, createElementNS: function (_ns, tag) { return makeNode(tag); }, + getElementById: function (id) { + return Object.prototype.hasOwnProperty.call(ID_INDEX, id) ? ID_INDEX[id] : null; + }, activeElement: null }; var CSS = null; diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 8a5fc817f..36af18947 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -22,7 +22,7 @@ # console (see auth.is_console_origin) and throttles per user on top of that. # Note /auth/pin (set/clear a PIN) is deliberately absent from this set: those # require a live session and must stay gated here. -EXEMPT_PATHS = {"/auth/login", "/auth/pin-login", "/auth/osk.js", "/auth/pin-panel.js", "/auth/lock-screen.js", "/auth/lock-widgets", "/auth/lock-weather", "/auth/lock-notifications", "/auth/lock-stats", "/auth/setup", "/auth/status", "/auth/me", "/auth/complete", "/auth/lock", "/api/health", "/api/version", "/setup", "/setup/complete", "/redeem", "/api/desktop/browser/push/vapid-public-key", "/api/desktop/browser/proxy-config", "/sw.js", "/desktop", "/desktop/index.html", "/chat-pwa", "/app.html", "/manifest", "/api/agents/registry/pubkey", "/api/share/destinations"} +EXEMPT_PATHS = {"/auth/login", "/auth/pin-login", "/auth/osk.js", "/auth/pin-panel.js", "/auth/lock-screen.js", "/auth/lock-widgets", "/auth/lock-weather", "/auth/lock-notifications", "/auth/lock-stats", "/auth/lock-panels", "/auth/setup", "/auth/status", "/auth/me", "/auth/complete", "/auth/lock", "/api/health", "/api/version", "/setup", "/setup/complete", "/redeem", "/api/desktop/browser/push/vapid-public-key", "/api/desktop/browser/proxy-config", "/sw.js", "/desktop", "/desktop/index.html", "/chat-pwa", "/app.html", "/manifest", "/api/agents/registry/pubkey", "/api/share/destinations"} # Registry feed endpoints accept EITHER an admin session OR a registry JWT. # When a Bearer token is present for these paths the request bypasses the diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index deade7592..2685ffdb3 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -434,7 +434,11 @@ def count(self, key: str) -> int: .ls-statusbar .ls-widget b { color: rgba(255,255,255,0.80); } .ls-brand { grid-column: 2; justify-self: center; } .ls-brand b { font-weight: 700; } -#ls-battery { grid-column: 3; justify-self: end; margin-right: 4px; } +/* 7px, not 4: Jay asked for the percentage 3px further left (it sat too close + to the rounded corner). It is justify-self:end, so the RIGHT margin is what + moves it -- padding or a transform would either move the brand with it or + leave the real box where it was. */ +#ls-battery { grid-column: 3; justify-self: end; margin-right: 7px; } /* Widgets are CLIENT-SIDE only (clock, battery) plus the device's own name. Nothing here reads the account or its data: this surface is shown BEFORE authentication, so anything account-derived would be a pre-auth leak. */ @@ -593,6 +597,142 @@ def count(self, key: str) -> int: } .ls-empty b { display: block; font-weight: 600; color: rgba(255,255,255,0.62); font-size: 15px; } +/* THE ROW. Phone, mailbox and decisions are all the same object -- a tinted + source mark, a line about it, and how long ago -- so they are one shape in + one material rather than three panels that happen to look similar. It is the + notification card's material deliberately: on this screen a missed call and a + notification ARE the same kind of thing. */ +.ls-row { + display: flex; align-items: flex-start; gap: 10px; + width: 100%; max-width: var(--ls-card-w); + padding: 10px 13px; + border-radius: 20px; + text-align: left; + background: rgba(30, 30, 34, 0.92); + box-shadow: 0 6px 18px -6px rgba(0, 0, 0, 0.75); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + /* Entrance animation, `backwards` like the islands. The whole point of + reconciling by key is that a row which persists across a repaint never + re-enters this animation -- see the repaint tests. */ + animation: ls-island-in 520ms cubic-bezier(0.32, 0.72, 0, 1) backwards; +} +.ls-row-tile { + flex: none; width: 30px; height: 30px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 700; color: #fff; + background: var(--ls-n, #4c9aff); +} +.ls-row-tile svg { width: 17px; height: 17px; fill: none; stroke: #fff; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.ls-row-body { min-width: 0; flex: 1; } +.ls-row-meta { + display: flex; align-items: baseline; gap: 6px; + font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; + color: rgba(255,255,255,0.45); +} +.ls-row-app { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ls-row-when { margin-left: auto; flex: none; text-transform: none; letter-spacing: 0; font-weight: 500; } +.ls-row-title { + margin-top: 2px; + font-size: 14px; font-weight: 600; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ls-row-sub { + margin-top: 1px; + font-size: 13px; line-height: 1.35; color: rgba(255,255,255,0.68); + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; +} +/* In a unified list the SUBJECT is what the eye lands on after the sender, so + it is brighter than the preview under it. */ +.ls-row-subject { color: rgba(255,255,255,0.88); font-weight: 500; -webkit-line-clamp: 1; } +/* A missed call is the one row whose SOURCE line is the alarming part, so the + red sits on "Missed call", not on the caller's name. */ +.ls-row[data-kind="missed"] .ls-row-app { color: #ff6b6b; } +/* Unread, in the place a phone puts it: a dot on the leading edge of the row. + It is drawn on the row rather than added as an element so marking something + read is one attribute, not a DOM change. */ +.ls-row[data-unread="1"] { border-left: 3px solid #4c9aff; padding-left: 10px; } + +/* APPS. A grid, because these are the only things on the screen the user picks + rather than reads. */ +.ls-apps-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; + width: 100%; max-width: var(--ls-card-w); +} +.ls-app { + display: flex; align-items: center; gap: 10px; + padding: 12px 13px; border-radius: 20px; + background: rgba(30, 30, 34, 0.92); + box-shadow: 0 6px 18px -6px rgba(0, 0, 0, 0.75); + animation: ls-island-in 520ms cubic-bezier(0.32, 0.72, 0, 1) backwards; +} +.ls-app-body { min-width: 0; flex: 1; } +.ls-app-name { + font-size: 14px; font-weight: 600; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ls-app-note { + margin-top: 1px; font-size: 12px; line-height: 1.3; color: rgba(255,255,255,0.6); + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; +} +/* The badge rides on the tile, the way it does on a home screen. The wrapper + exists so the badge is a sibling of the mark rather than a child of it -- + the mark's contents are rewritten by the painter. */ +.ls-app-tile { position: relative; flex: none; } +.ls-app-badge { + position: absolute; top: -6px; right: -7px; + min-width: 17px; height: 17px; padding: 0 4px; box-sizing: border-box; + border-radius: 999px; background: #ff3b30; color: #fff; + font-size: 11px; font-weight: 700; line-height: 17px; text-align: center; + box-shadow: 0 0 0 2px rgba(20,20,22,0.92); +} + +/* DECISIONS. The only rows on this screen the user ANSWERS, so they carry + buttons and the buttons are the widest thing in the card. */ +.ls-dec-actions { display: flex; gap: 8px; margin-top: 9px; } +.ls-dec-btn { + flex: 1; padding: 8px 10px; border: 0; border-radius: 12px; + font: inherit; font-size: 13px; font-weight: 600; color: #fff; + background: rgba(255,255,255,0.12); +} +.ls-dec-btn[data-act="approve"] { background: rgba(48,209,88,0.22); color: #6ee787; } +.ls-dec-btn:focus-visible { outline: 3px solid #4c9aff; outline-offset: 2px; } +.ls-dec-done { + margin-top: 9px; font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.6); +} +.ls-row[data-answered="1"] .ls-dec-actions { display: none; } + +/* PROJECTS. The tab that replaced settings, second in the row after the + agents: this is a projects-focused OS, so what the agents are working ON + belongs next to the agents themselves. Same row material as everything else + here, plus the one quantity on this screen. */ +.ls-proj-bar { + margin-top: 8px; height: 4px; border-radius: 999px; + background: rgba(255,255,255,0.14); overflow: hidden; +} +.ls-proj-fill { + display: block; height: 100%; width: var(--ls-pct, 0%); + border-radius: 999px; background: var(--ls-n, #4c9aff); + /* The width is written by the painter on a node that PERSISTS across a + repaint, so this animates from where it was rather than from zero. Had the + rows been rebuilt, every bar would have re-run this from 0% every poll -- + the same flicker as the islands, in a different costume. */ + transition: width 420ms cubic-bezier(0.32, 0.72, 0, 1); +} +.ls-row[data-blocked="1"] .ls-proj-fill { background: #ffb020; } +/* Blocked: work that has stopped and is waiting on a person. It is the reason + this panel is on a LOCK screen, so it is the one thing in the row that is + allowed to shout. */ +.ls-proj-flag { + flex: none; padding: 1px 7px; border-radius: 999px; + background: rgba(255,176,32,0.18); color: #ffb020; + letter-spacing: 0.04em; +} +@media (prefers-reduced-motion: reduce) { + .ls-row, .ls-app { animation: none; } + .ls-proj-fill { transition: none; } +} + /* THE STATS CARD. One card in the same material as an island, so the system readings read as another thing this screen shows rather than as a settings page that wandered in. */ @@ -1561,6 +1701,14 @@ def _pin_panel_html(next_url: str, keypad: bool = False) -> str: + + + + + + @@ -1575,13 +1723,6 @@ def _pin_panel_html(next_url: str, keypad: bool = False) -> str: - - - - - - """ @@ -1615,13 +1756,18 @@ def _device_label() -> str: # is #ls-notifs. Deriving the id would have pointed aria-controls at the inner # agents box and at an #ls-alerts that does not exist. _LOCK_VIEWS = ( + # Jay's order, from the glass: agents, projects, alerts, mailbox, phone, + # stats -- then apps, which he asked to keep but did not place. Projects + # sits second because this is a projects-focused OS and it replaced the + # settings tab outright; pending decisions are not a tab of their own, they + # ride at the top of ALERTS where they can be answered quickly. ("agents", "Agents", "lv-agents", "ls-activity"), - ("phone", "Phone", "lv-phone", "ls-phone"), - ("mailbox", "Mailbox", "lv-mailbox", "ls-mailbox"), - ("apps", "Apps", "lv-apps", "ls-apps"), + ("projects", "Projects", "lv-projects", "ls-projects"), ("alerts", "Alerts", "lv-alerts", "ls-notifs"), + ("mailbox", "Mailbox", "lv-mailbox", "ls-mailbox"), + ("phone", "Phone", "lv-phone", "ls-phone"), ("stats", "System", "lv-stats", "ls-stats"), - ("settings", "Settings", "lv-settings", "ls-settings"), + ("apps", "Apps", "lv-apps", "ls-apps"), ) _LOCK_DEFAULT_VIEW = "agents" @@ -1688,17 +1834,23 @@ def _lock_head_html() -> str:
+ role="tabpanel" aria-labelledby="ls-tab-alerts" aria-label="Alerts" hidden> + +
+ + - {_FRAMEWORK_SPRITE} {_VIEW_SPRITE} @@ -2623,7 +2775,9 @@ def _lock_tail_html() -> str: var NOTIF_GLYPHS = { mail: '', phone: '', - sms: '' + sms: '', + // Two rings joined by a bar: the mark every phone uses for voicemail. + voicemail: '' }; // Which stacks the user has fanned out, kept OUTSIDE the paint so a repaint @@ -2798,6 +2952,14 @@ def _lock_tail_html() -> str: delete existing[source]; want.push(el); } + // Pending decisions ride at the TOP of this panel -- they are the only + // thing in it the user ANSWERS rather than reads. Included in `want` + // rather than left where they sit, because placeInOrder removes + // everything past the last wanted element: left out, the decisions would + // be deleted by the next notification poll. + var decEl = document.getElementById("ls-decisions"); + if (decEl && decEl.children.length) want.unshift(decEl); + placeInOrder(notifsEl, want); // The minute labels are retouched in place on their own timer, so the @@ -2833,6 +2995,335 @@ def _lock_tail_html() -> str: }, 60000); } + // ------------------------------------------------------------------ + // THE SCRIPTED PANELS: phone, mailbox, apps, decisions, settings. + // + // Five more pollers on a screen whose last bug was "every poller that wipes + // its container and rebuilds makes the whole panel flicker". So not one of + // these ever wipes. Every row is found by its payload key through partOf(), + // updated in place through setText()/setAttrIfChanged(), and ordered with + // placeInOrder() -- a row that persists across a repaint keeps its identity + // and therefore never replays its entrance animation. That is the property + // the repaint tests assert, and it is why these were built this way from + // the first line rather than fixed afterwards five times over. + // + // Everything here is scripted demo content served by /auth/lock-panels, + // which 404s unless the demo flags are on. This screen renders BEFORE + // sign-in: there is no code path from any of it to a real account. + // ------------------------------------------------------------------ + var panelEls = { + phone: document.getElementById("ls-phone"), + mailbox: document.getElementById("ls-mailbox"), + apps: document.getElementById("ls-apps"), + projects: document.getElementById("ls-projects"), + // Not a panel of its own: this container lives INSIDE the alerts panel, + // above the notification stacks. + decisions: document.getElementById("ls-decisions") + }; + // Every minute label currently on a panel, rebuilt from the DOM after each + // paint so a row left untouched still gets its minutes retouched. + var panelClocks = []; + // User state that must OUTLIVE a repaint: which decisions they have + // answered. Kept out here for the same reason notifOpen is -- a paint must + // never undo what the user just did, and a poll lands whatever they are in + // the middle of. + var decAnswered = {}; + + // The tile every row leads with. Written only when it changes, so a repaint + // of an unchanged row touches no DOM at all. + function paintTile(tile, spec) { + // Only a colour literal is ever taken from the payload, and only after it + // is checked -- an unchecked value here would be written into a style. + if (/^#[0-9a-fA-F]{3,8}$/.test(spec.tint || "") + && tile.style.getPropertyValue("--ls-n") !== spec.tint) { + tile.style.setProperty("--ls-n", spec.tint); + } + var glyph = (spec.glyph && NOTIF_GLYPHS[spec.glyph]) ? spec.glyph : ""; + if (tile.getAttribute("data-glyph") !== glyph) { + tile.setAttribute("data-glyph", glyph); + // innerHTML only ever from NOTIF_GLYPHS, which is a literal in this + // file. The payload chooses a key; it never supplies markup. + tile.innerHTML = glyph ? '' + NOTIF_GLYPHS[glyph] + "" : ""; + } + if (!glyph) setText(tile, (spec.mono || spec.app || "?").slice(0, 2)); + } + + // The head of a row: tile, APP · when, title, and up to two sub-lines. + // + // `extra` is a function given the body element and returning any further + // parts to sit under the sub-lines. It exists because this function ends in + // placeInOrder(), which REMOVES everything past the last wanted element -- + // a caller that appended its buttons afterwards would have them deleted on + // the next repaint and silently rebuilt, which is the very rebuild all of + // this is here to avoid. + function paintRowHead(parent, item, cls, title, sub, subject, extra) { + var row = partOf(parent, item.key, cls); + var tile = partOf(row, "tile", "ls-row-tile"); + paintTile(tile, item); + var body = partOf(row, "body", "ls-row-body"); + var meta = partOf(body, "meta", "ls-row-meta"); + var app = setText(partOf(meta, "app", "ls-row-app"), item.app || ""); + var parts = [app]; + if (item.at) { + var when = setText(partOf(meta, "when", "ls-row-when"), whenText(item.at)); + when.setAttribute("data-at", item.at); + parts.push(when); + } + placeInOrder(meta, parts); + var bodyParts = [meta, setText(partOf(body, "title", "ls-row-title"), title)]; + if (subject) { + bodyParts.push(setText(partOf(body, "subject", "ls-row-sub ls-row-subject"), subject)); + } + if (sub) bodyParts.push(setText(partOf(body, "sub", "ls-row-sub"), sub)); + if (extra) bodyParts = bodyParts.concat(extra(body)); + placeInOrder(body, bodyParts); + placeInOrder(row, [tile, body]); + return row; + } + + // "Nothing here" rather than a blank screen, so a panel that served no rows + // is distinguishable from one that failed to load. + function paintEmpty(el, head, line) { + var empty = partOf(el, "empty", "ls-empty"); + var b = setText(partOf(empty, "head", "", "b"), head); + var p = setText(partOf(empty, "line", "", "span"), line); + placeInOrder(empty, [b, p]); + placeInOrder(el, [empty]); + } + + function paintPhone(items) { + var el = panelEls.phone; + if (!el) return; + if (!items.length) return paintEmpty(el, "No missed calls", "The dialer and your agents' lines are quiet."); + var want = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + var row = paintRowHead(el, it, "ls-row ls-call", it.who || "", it.detail || "", ""); + setAttrIfChanged(row, "data-kind", it.kind || "missed"); + want.push(row); + } + placeInOrder(el, want); + } + + function paintMailbox(items) { + var el = panelEls.mailbox; + if (!el) return; + if (!items.length) return paintEmpty(el, "Nothing new", "Mail, messages and DMs all read."); + var want = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + // One stream, ordered by arrival, each line saying where it came from: + // that IS the unified-inbox design, not decoration on top of one. + var row = paintRowHead(el, it, "ls-row ls-msg", it.who || "", it.preview || "", it.subject || ""); + if (it.unread) setAttrIfChanged(row, "data-unread", "1"); + else if (row.hasAttribute("data-unread")) row.removeAttribute("data-unread"); + want.push(row); + } + placeInOrder(el, want); + } + + function paintApps(items) { + var el = panelEls.apps; + if (!el) return; + if (!items.length) return paintEmpty(el, "No apps", "Nothing is waiting in your apps."); + var grid = partOf(el, "grid", "ls-apps-grid"); + var want = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + var tile = partOf(grid, it.key, "ls-app"); + // The badge is a SIBLING of the mark, not a child of it: paintTile owns + // the mark's contents outright -- it writes the monogram with setText, + // which replaces every child -- so a badge parented there would be + // wiped on the first paint of any tile without a glyph, which is all + // four of these. + var wrap = partOf(tile, "tile", "ls-app-tile"); + var mark = partOf(wrap, "mark", "ls-row-tile"); + paintTile(mark, it); + var wrapParts = [mark]; + if (it.badge) { + wrapParts.push(setText(partOf(wrap, "badge", "ls-app-badge", "span"), String(it.badge))); + } + placeInOrder(wrap, wrapParts); + var body = partOf(tile, "body", "ls-app-body"); + var name = setText(partOf(body, "name", "ls-app-name"), it.app || ""); + var note = setText(partOf(body, "note", "ls-app-note"), it.note || ""); + placeInOrder(body, [name, note]); + placeInOrder(tile, [wrap, body]); + want.push(tile); + } + placeInOrder(grid, want); + placeInOrder(el, [grid]); + } + + function paintDecisions(items) { + var el = panelEls.decisions; + if (!el) return; + // No "nothing to decide" card: this container is not a panel, it is the + // head of the ALERTS panel, and an empty-state card sitting above the + // notification stacks would be noise on the screen the user opened to + // read the stacks. With nothing pending it empties and steps out of the + // way entirely. + if (!items.length) { + placeInOrder(el, []); + if (el.parentNode) el.parentNode.removeChild(el); + return; + } + var want = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + // The agent that is blocked is this row's "source", so a decision reads + // as "who is waiting on me" in the same shape as everything else here. + var spec = { + key: it.key, app: it.agent || "agent", at: it.at, + mono: (it.agent || "?").slice(0, 2), tint: "#ffb020" + }; + var row = paintRowHead(el, spec, "ls-row ls-dec", it.title || "", it.detail || "", "", + (function (key) { + return function (body) { + var answer = decAnswered[key]; + if (answer) { + return [setText(partOf(body, "done", "ls-dec-done"), + (answer === "approve" ? "Approved" : "Denied") + " (demo)")]; + } + var actions = partOf(body, "actions", "ls-dec-actions"); + var deny = partOf(actions, "deny", "ls-dec-btn", "button"); + var approve = partOf(actions, "approve", "ls-dec-btn ls-dec-approve", "button"); + // Wired once, when the elements are first created: re-binding on + // every paint is how a reconciled list quietly grows duplicate + // handlers and fires an action four times on the fourth repaint. + if (deny.type !== "button") { + deny.type = "button"; + deny.setAttribute("data-act", "deny"); + setText(deny, "Not now"); + approve.type = "button"; + approve.setAttribute("data-act", "approve"); + setText(approve, "Approve"); + actions.addEventListener("click", function (ev) { + var btn = ev.target.closest ? ev.target.closest(".ls-dec-btn") : null; + if (!btn) return; + // DEMO ONLY. This screen renders before sign-in, so an answer + // is remembered in the page and goes nowhere near an agent. + decAnswered[key] = btn.getAttribute("data-act"); + paintDecisions(lastPanels.decisions || []); + }); + } + placeInOrder(actions, [deny, approve]); + return [actions]; + }; + })(it.key)); + if (decAnswered[it.key]) setAttrIfChanged(row, "data-answered", "1"); + want.push(row); + } + placeInOrder(el, want); + // Put the container back at the top of the alerts panel if a + // notification paint has dropped it: paintNotifications ends in + // placeInOrder(notifsEl, groups), which removes everything past the last + // group, and the two run on independent timers. Without this the + // decisions would survive in a detached node -- painted, correct, and + // invisible, which is the worst of the three. + if (notifsEl && el.parentNode !== notifsEl) { + notifsEl.insertBefore(el, notifsEl.firstChild); + } + if (notifsEl) notifsEl.hidden = false; + } + + function paintProjects(items) { + var el = panelEls.projects; + if (!el) return; + if (!items.length) return paintEmpty(el, "No projects", "Nothing is on the go."); + var want = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + var row = partOf(el, it.key, "ls-row ls-project"); + // Blocked is the state this panel exists to surface: work that has + // stopped and is waiting on a person. + if (it.blocked) setAttrIfChanged(row, "data-blocked", "1"); + else if (row.hasAttribute("data-blocked")) row.removeAttribute("data-blocked"); + var tile = partOf(row, "tile", "ls-row-tile"); + paintTile(tile, it); + var body = partOf(row, "body", "ls-row-body"); + var meta = partOf(body, "meta", "ls-row-meta"); + var who = setText(partOf(meta, "app", "ls-row-app"), + it.agents === 1 ? "1 agent" : (it.agents || 0) + " agents"); + var metaParts = [who]; + if (it.blocked) { + metaParts.push(setText(partOf(meta, "flag", "ls-proj-flag", "span"), "Blocked")); + } + if (it.at) { + var when = setText(partOf(meta, "when", "ls-row-when"), whenText(it.at)); + when.setAttribute("data-at", it.at); + metaParts.push(when); + } + placeInOrder(meta, metaParts); + var name = setText(partOf(body, "title", "ls-row-title"), it.name || ""); + var note = setText(partOf(body, "sub", "ls-row-sub"), it.note || ""); + // The bar is the only quantity on this screen, so it is drawn rather + // than written: a row of percentages reads as a spreadsheet. + var bar = partOf(body, "bar", "ls-proj-bar"); + var fill = partOf(bar, "fill", "ls-proj-fill"); + var pct = Math.max(0, Math.min(100, Number(it.progress) || 0)); + if (fill.style.getPropertyValue("--ls-pct") !== pct + "%") { + fill.style.setProperty("--ls-pct", pct + "%"); + } + setAttrIfChanged(bar, "role", "progressbar"); + setAttrIfChanged(bar, "aria-valuenow", String(pct)); + setAttrIfChanged(bar, "aria-valuemin", "0"); + setAttrIfChanged(bar, "aria-valuemax", "100"); + setAttrIfChanged(bar, "aria-label", (it.name || "Project") + " progress"); + placeInOrder(bar, [fill]); + placeInOrder(body, [meta, name, note, bar]); + placeInOrder(row, [tile, body]); + want.push(row); + } + placeInOrder(el, want); + } + + // The last payload, so an in-page answer can repaint one panel without + // waiting for the next poll. + var lastPanels = {}; + + function paintPanels(data) { + lastPanels = data || {}; + paintPhone(data.phone || []); + paintMailbox(data.mailbox || []); + paintApps(data.apps || []); + paintProjects(data.projects || []); + paintDecisions(data.decisions || []); + // Rebuilt from what is ACTUALLY on screen, for the same reason the + // notification stacks do it: rows left untouched still own their labels. + panelClocks = []; + for (var name in panelEls) { + if (!panelEls[name]) continue; + var whens = panelEls[name].querySelectorAll(".ls-row-when[data-at]"); + for (var k = 0; k < whens.length; k++) { + panelClocks.push({ el: whens[k], at: Number(whens[k].getAttribute("data-at")) }); + } + } + syncFeedFade(); + } + + function pollPanels() { + fetch("/auth/lock-panels", { credentials: "same-origin" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d) paintPanels(d); }) + // 404 is the ordinary answer with demo content off: the panels stay + // empty and say so. Not an error. + .catch(function () { /* leave the panels as they are */ }); + } + if (panelEls.phone || panelEls.mailbox || panelEls.apps + || panelEls.projects || panelEls.decisions) { + pollPanels(); + // Scripted tables do not change, so this is slow on purpose: it exists to + // pick the content up if the flag is turned on while the phone is sitting + // on the lock screen, not to animate anything. + setInterval(pollPanels, 15 * 60 * 1000); + setInterval(function () { + for (var i = 0; i < panelClocks.length; i++) { + panelClocks[i].el.textContent = whenText(panelClocks[i].at); + } + }, 60000); + } + // Switching to the password form STAYS on the lock screen. // // This used to drop .lockscreen-on to get the ordinary card and the shared @@ -4734,6 +5225,20 @@ def _demo_notifications_enabled() -> bool: return bool(os.environ.get("TAOS_LOCK_DEMO_NOTIFICATIONS", "").strip()) +def _demo_panels_enabled() -> bool: + """Whether the scripted phone/mailbox/apps/decisions/settings panels are on. + + Same two-flag shape as the stacks, and for the same reason: the master flag + must remain the one move that takes down everything invented on this + pre-sign-in screen. It also means a device can run the agent islands -- the + part of this screen that shows REAL state -- with none of the scripted + inbox content beside them. + """ + if not _demo_enabled(): + return False + return bool(os.environ.get("TAOS_LOCK_DEMO_PANELS", "").strip()) + + def _demo_thread(slug: str) -> list[dict]: """Build one scripted thread as absolute timestamps relative to now.""" script = _DEMO_THREADS.get(slug, _DEMO_THREAD_FALLBACK) @@ -5042,6 +5547,348 @@ def _demo_notifications() -> list[dict]: return groups +#: The four panels the view row reaches and nothing had ever put anything in: +#: phone, mailbox, apps and decisions, plus the settings sheet. Same rule as the +#: notification stacks and for the same reason -- THIS SCREEN RENDERS BEFORE +#: SIGN-IN, so every line here is scripted and server-side and there is no code +#: path from any of it to a real account. A "helpful" wiring of the mailbox to +#: the user's actual inbox would be a pre-auth leak, not a feature. +#: +#: `at` offsets are minutes-ago rather than timestamps, so the phone reads as +#: having had a plausible morning whenever the demo is run. +#: +#: Phone numbers are drawn from Ofcom's 07700 900xxx drama range, which is +#: reserved for fiction and can never reach a real subscriber. +_DEMO_PHONE: tuple[dict, ...] = ( + { + "key": "call-kenwright", + "kind": "missed", + "app": "Phone", + "who": "Dave Kenwright", + "detail": "Mobile · 07700 900461", + "minutes": 22, + "glyph": "phone", + "tint": "#34c759", + }, + { + "key": "call-wa-brightside", + "kind": "missed", + # Jay named the app by the name it carries on the phone. + "app": "WA+", + "who": "Brightside Joinery", + "detail": "WhatsApp Business · voice call", + "minutes": 47, + "mono": "WA", + "tint": "#25d366", + }, + { + "key": "call-twilio-agent", + "kind": "missed", + "app": "Twilio", + "who": "taOS agent line", + # The one entry that is about the product rather than the person: an + # agent holds a phone number and something rang it while the user was + # away. That is the whole point of the demo. + "detail": "Inbound · 07700 900118 · agent was mid-task", + "minutes": 63, + "mono": "TW", + "tint": "#f22f46", + }, + { + "key": "call-wa-ellis", + "kind": "missed", + "app": "WA+", + "who": "Ellis & Daughters", + "detail": "WhatsApp Business · 2 calls", + "minutes": 140, + "mono": "WA", + "tint": "#25d366", + }, + { + "key": "voicemail-hargreaves", + "kind": "voicemail", + "app": "Voicemail", + "who": "Hargreaves & Co", + "detail": "0:38 · “…bringing the revised drawings Thursday…”", + "minutes": 96, + "glyph": "voicemail", + "tint": "#8e8e93", + }, +) + +#: Unified messaging, explicitly the BlackBerry Hub shape Jay asked for: mail, +#: SMS, X DMs and LinkedIn in ONE stream ordered by arrival. The per-item source +#: is the design, not decoration -- a unified list that does not say where each +#: line came from is just a worse inbox. +_DEMO_MAILBOX: tuple[dict, ...] = ( + { + "key": "mail-hargreaves", + "source": "mail", + "app": "Mail", + "who": "Hargreaves & Co", + "subject": "Re: Thursday's site visit", + "preview": "09:15 works for us. I'll bring the revised drawings.", + "minutes": 12, + "glyph": "mail", + "tint": "#2f6fd0", + "unread": True, + }, + { + "key": "dm-x-marcus", + "source": "x", + "app": "X", + "who": "@marcus_dev", + "subject": "Direct message", + "preview": "what's the actual memory floor for running this on a 4GB board?", + "minutes": 26, + "mono": "X", + "tint": "#3b3b42", + "unread": True, + }, + { + "key": "sms-sam", + "source": "sms", + "app": "Messages", + "who": "Sam", + "subject": "SMS", + "preview": "are you still alright for Sunday?", + "minutes": 19, + "glyph": "sms", + "tint": "#25c05d", + "unread": True, + }, + { + "key": "li-recruiter", + "source": "linkedin", + "app": "LinkedIn", + "who": "Priya Raman", + "subject": "InMail", + "preview": "Saw the on-device agent work — are you open to a conversation?", + "minutes": 88, + "mono": "in", + "tint": "#0a66c2", + "unread": True, + }, + { + "key": "mail-companies-house", + "source": "mail", + "app": "Mail", + "who": "Companies House", + "subject": "Confirmation statement due 3 October", + "preview": "No action needed if your details are unchanged.", + "minutes": 74, + "glyph": "mail", + "tint": "#2f6fd0", + "unread": False, + }, + { + "key": "li-post", + "source": "linkedin", + "app": "LinkedIn", + "who": "Northlight Systems", + "subject": "Message", + "preview": "Thanks for the demo yesterday — sending the write-up over.", + "minutes": 190, + "mono": "in", + "tint": "#0a66c2", + "unread": False, + }, + { + "key": "sms-o2", + "source": "sms", + "app": "Messages", + "who": "O2", + "subject": "SMS", + "preview": "You've used 80% of your data allowance this month.", + "minutes": 310, + "glyph": "sms", + "tint": "#25c05d", + "unread": False, + }, +) + +#: The four apps Jay named. A badge is a count; `note` is the one line the tile +#: shows underneath, because a grid of bare icons on a lock screen says nothing +#: a user could act on. +_DEMO_APPS: tuple[dict, ...] = ( + { + "key": "app-instagram", + "app": "Instagram", + "mono": "ig", + "tint": "#c13584", + "badge": 7, + "note": "3 DMs, 4 mentions", + }, + { + "key": "app-reddit", + "app": "Reddit", + "mono": "r", + "tint": "#ff4500", + "badge": 12, + "note": "r/selfhosted replies", + }, + { + "key": "app-bank", + "app": "Bank", + "mono": "£", + "tint": "#1b7f5a", + "badge": 1, + # A balance would be the one genuinely sensitive-looking line on a + # pre-auth screen, so the tile says a payment needs a look and no more. + "note": "Card payment needs approval", + }, + { + "key": "app-youtube", + "app": "YouTube", + "mono": "▶", + "tint": "#ff0000", + "badge": 3, + "note": "3 new from your subscriptions", + }, +) + +#: Pending approvals waiting on the user. These are the lock screen's reason to +#: exist: an agent got far enough to need a human and stopped. Each carries the +#: agent that is blocked, so the panel reads as "who is waiting on me". +_DEMO_DECISIONS: tuple[dict, ...] = ( + { + "key": "dec-invoice", + "title": "Pay Brightside Joinery invoice", + "detail": "£1,840.00 · matches quote BJ-2291 · due Friday", + "agent": "finance", + "minutes": 31, + }, + { + "key": "dec-reply", + "title": "Send drafted reply to Hargreaves & Co", + "detail": "Confirms 09:15 Thursday and asks for parking details", + "agent": "inbox", + "minutes": 54, + }, + { + "key": "dec-deploy", + "title": "Deploy taos-website build 412", + "detail": "All checks green · changes the pricing page copy", + "agent": "builder", + "minutes": 120, + }, +) + +#: PROJECTS -- the tab that replaced settings (Jay: "makes sense as its a +#: projects focused os"). It sits second in the row, right after the agents. +#: +#: A project is a body of work with agents on it, so each row says how far along +#: it is, how many agents are working it, and the one thing that happened most +#: recently. `blocked` is the state the lock screen exists to surface: work that +#: has stopped and is waiting on a person. +#: +#: Read-only, and deliberately so. This replaced a panel of pre-auth ACTIONS +#: ("stop all agents" reachable by anyone holding the phone), and swapping it +#: for content removed that exposure rather than moving it somewhere else. +_DEMO_PROJECTS: tuple[dict, ...] = ( + { + "key": "prj-brightside", + "name": "Brightside Joinery fit-out", + "note": "Quote accepted · scheduling the survey", + "progress": 72, + "agents": 3, + "blocked": True, + "mono": "BJ", + "tint": "#ffb020", + "minutes": 31, + }, + { + "key": "prj-taos-site", + "name": "taOS website relaunch", + "note": "Build 412 green · pricing copy rewritten", + "progress": 88, + "agents": 2, + "blocked": False, + "mono": "tw", + "tint": "#4c9aff", + "minutes": 54, + }, + { + "key": "prj-handset", + "name": "Handset demo build", + "note": "Lock screen panels landed · splash handover next", + "progress": 64, + "agents": 4, + "blocked": False, + "mono": "hd", + "tint": "#30d158", + "minutes": 12, + }, + { + "key": "prj-accounts", + "name": "Year end accounts", + "note": "Waiting on two receipts · filing due 3 October", + "progress": 40, + "agents": 1, + "blocked": True, + "mono": "ya", + "tint": "#bf5af2", + "minutes": 190, + }, + { + "key": "prj-northlight", + "name": "Northlight pilot", + "note": "Write-up drafted, ready to send", + "progress": 95, + "agents": 1, + "blocked": False, + "mono": "np", + "tint": "#64d2ff", + "minutes": 300, + }, +) + + +def _demo_panels() -> dict: + """Every scripted panel, timestamped relative to now and newest-first. + + One payload for all five rather than an endpoint each: they are all the same + switch, they are all static tables, and five pollers on one screen is five + chances to repaint something the user is reading. The client paints each + panel from its own key, so a panel the payload omits is simply empty. + """ + now = time.time() + + def stamped(rows: tuple[dict, ...]) -> list[dict]: + out = [] + for spec in rows: + item = dict(spec) + if "minutes" in item: + item["at"] = now - (item.pop("minutes") * 60) + # Marked at construction, like the stacks: nothing downstream should + # have to work out that these are placeholders by elimination. + item["demo"] = True + out.append(item) + return out + + phone = stamped(_DEMO_PHONE) + phone.sort(key=lambda item: item["at"], reverse=True) + mailbox = stamped(_DEMO_MAILBOX) + mailbox.sort(key=lambda item: item["at"], reverse=True) + decisions = stamped(_DEMO_DECISIONS) + decisions.sort(key=lambda item: item["at"], reverse=True) + # Projects lead with whatever moved most recently, the way the rest of this + # screen does -- except that anything BLOCKED comes first regardless. A + # project waiting on a person is the reason to look at this panel, and it + # going quiet is precisely what would sink it to the bottom of a pure + # recency sort. + projects = stamped(_DEMO_PROJECTS) + projects.sort(key=lambda item: (item["blocked"], item["at"]), reverse=True) + return { + "phone": phone, + "mailbox": mailbox, + # Apps are a fixed grid: their order is the author's, not the clock's. + "apps": stamped(_DEMO_APPS), + "decisions": decisions, + "projects": projects, + } + + #: Previous /proc/stat reading, so CPU can be a PERCENTAGE. A single sample of #: /proc/stat gives cumulative jiffies since boot; dividing those by uptime #: yields the average load since the phone was switched on, which on a device @@ -5278,6 +6125,30 @@ async def lock_notifications(request: Request): return JSONResponse({"groups": _demo_notifications(), "demo": True}) +@router.get("/lock-panels") +async def lock_panels(request: Request): + """Scripted contents of the phone, mailbox, apps, projects and decisions + panels. Console-only, demo-only. + + Gated exactly like the notification stacks: TAOS_LOCK_DEMO_PANELS on top of + the master TAOS_LOCK_DEMO_AGENTS flag, so a real device shows five empty + panels rather than an invented inbox, and one flag takes the whole lot down. + 404 with either flag off; the page treats that as "nothing to show". + + Everything served here is READ-ONLY content. The panel that used to carry + actions ("stop all agents", reachable by anyone holding the phone) was + replaced by projects, which removed that pre-auth exposure rather than + relocating it. + """ + if not _request_is_console(request): + return JSONResponse({"error": "console only"}, status_code=403) + if not _demo_panels_enabled(): + return JSONResponse({"error": "not found"}, status_code=404) + payload = _demo_panels() + payload["demo"] = True + return JSONResponse(payload) + + @router.post("/pin-login") async def pin_login(request: Request): """Sign in with a PIN. Console-only. From 943d2e7d79ddaf809dcd53ffc49646efeb0de399 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 16 Sep 2026 20:36:34 +0000 Subject: [PATCH 02/33] More demo content in every lock-screen panel Jay, from the glass: "theres not enough". Phone 5 -> 10, mailbox 7 -> 13, apps 4 -> 8, decisions 3 -> 7, projects 5 -> 8. Two tests had written the old counts in as literals and went red on content being added, which is backwards: the content is Jay's to grow. Both now derive from the table -- the apps assertion requires his four as a SUBSET rather than an exact set, and the phone assertion counts missed calls from the table instead of asserting four. Docs-Reviewed: scripted demo rows only. No route, schema, flag or user-facing behaviour changed, so docs/agent-coordination.md and the changelog fragment already added for this branch still describe it exactly. --- tests/test_lock_demo_panels.py | 14 ++- tinyagentos/routes/auth.py | 216 +++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 4 deletions(-) diff --git a/tests/test_lock_demo_panels.py b/tests/test_lock_demo_panels.py index f10f8c9d2..0cd3addd9 100644 --- a/tests/test_lock_demo_panels.py +++ b/tests/test_lock_demo_panels.py @@ -203,9 +203,11 @@ def test_every_message_says_where_it_came_from(self): for item in auth._demo_panels()["mailbox"]: assert item["app"], item - def test_the_apps_panel_is_the_four_apps_jay_named(self): + def test_the_apps_panel_carries_the_four_apps_jay_named(self): + """A superset is fine -- he asked for more content, not fewer apps -- + but his four are the ones he named and must all be there.""" apps = {item["app"] for item in auth._demo_panels()["apps"]} - assert apps == {"Instagram", "Reddit", "Bank", "YouTube"}, apps + assert {"Instagram", "Reddit", "Bank", "YouTube"} <= apps, apps def test_the_bank_tile_shows_no_balance(self): """The one genuinely sensitive-looking line on a pre-auth screen. The @@ -746,8 +748,12 @@ def test_the_phone_panel_renders_a_row_per_call(self): one, = _run([_payload()]) assert _keys(one, "phone") == [i["key"] for i in auth._demo_panels()["phone"]] missed = [r for r in one["phone"] if r["kind"] == "missed"] - assert len(missed) == 4, one["phone"] - assert any(r["kind"] == "voicemail" for r in one["phone"]) + voicemail = [r for r in one["phone"] if r["kind"] == "voicemail"] + # Counts come from the table rather than being written in: the content + # is Jay's to grow, and a hard number here turns every addition red. + assert len(missed) == len( + [i for i in auth._demo_panels()["phone"] if i["kind"] == "missed"]) + assert missed and voicemail, one["phone"] def test_the_mailbox_marks_unread_rows_and_leaves_read_ones_alone(self): """Both arms. An attribute set on everything is not a mark.""" diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index 2685ffdb3..23ddc2c27 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -5604,6 +5604,56 @@ def _demo_notifications() -> list[dict]: "mono": "WA", "tint": "#25d366", }, + { + "key": "call-dentist", + "kind": "missed", + "app": "Phone", + "who": "Mersey Dental Practice", + "detail": "Mobile · 07700 900233", + "minutes": 8, + "glyph": "phone", + "tint": "#34c759", + }, + { + "key": "call-wa-northlight", + "kind": "missed", + "app": "WA+", + "who": "Northlight Systems", + "detail": "WhatsApp Business · video call", + "minutes": 35, + "mono": "WA", + "tint": "#25d366", + }, + { + "key": "call-twilio-outbound", + "kind": "missed", + "app": "Twilio", + "who": "taOS agent line", + "detail": "Callback requested · 07700 900874", + "minutes": 112, + "mono": "TW", + "tint": "#f22f46", + }, + { + "key": "call-unknown", + "kind": "missed", + "app": "Phone", + "who": "No caller ID", + "detail": "Mobile · 2 calls", + "minutes": 171, + "glyph": "phone", + "tint": "#34c759", + }, + { + "key": "voicemail-brightside", + "kind": "voicemail", + "app": "Voicemail", + "who": "Brightside Joinery", + "detail": "1:12 · \u201c\u2026chasing the invoice, give us a ring\u2026\u201d", + "minutes": 210, + "glyph": "voicemail", + "tint": "#8e8e93", + }, { "key": "voicemail-hargreaves", "kind": "voicemail", @@ -5693,6 +5743,78 @@ def _demo_notifications() -> list[dict]: "tint": "#0a66c2", "unread": False, }, + { + "key": "dm-x-agentdev", + "source": "x", + "app": "X", + "who": "@agentops", + "subject": "Direct message", + "preview": "Would you do a walkthrough of the lock screen for the newsletter?", + "minutes": 44, + "mono": "X", + "tint": "#3b3b42", + "unread": True, + }, + { + "key": "mail-stripe", + "source": "mail", + "app": "Mail", + "who": "Payments", + "subject": "Payout of \u00a32,410.00 is on its way", + "preview": "Expected in your account on Thursday.", + "minutes": 51, + "glyph": "mail", + "tint": "#2f6fd0", + "unread": True, + }, + { + "key": "sms-dentist", + "source": "sms", + "app": "Messages", + "who": "Mersey Dental", + "subject": "SMS", + "preview": "Reminder: appointment Friday 11:20. Reply C to confirm.", + "minutes": 63, + "glyph": "sms", + "tint": "#25c05d", + "unread": True, + }, + { + "key": "li-northlight", + "source": "linkedin", + "app": "LinkedIn", + "who": "Dan Mercer", + "subject": "Message", + "preview": "Good to meet you Tuesday \u2014 sending the pilot scope across.", + "minutes": 121, + "mono": "in", + "tint": "#0a66c2", + "unread": False, + }, + { + "key": "mail-hosting", + "source": "mail", + "app": "Mail", + "who": "Hetzner", + "subject": "Scheduled maintenance, Sunday 02:00\u201304:00 UTC", + "preview": "One reboot expected. No action required.", + "minutes": 240, + "glyph": "mail", + "tint": "#2f6fd0", + "unread": False, + }, + { + "key": "dm-x-liverpool", + "source": "x", + "app": "X", + "who": "@anfieldwatch", + "subject": "Direct message", + "preview": "spare for Newcastle if you still want one", + "minutes": 275, + "mono": "X", + "tint": "#3b3b42", + "unread": False, + }, { "key": "sms-o2", "source": "sms", @@ -5745,6 +5867,39 @@ def _demo_notifications() -> list[dict]: "badge": 3, "note": "3 new from your subscriptions", }, + + { + "key": "app-whatsapp", + "app": "WhatsApp", + "mono": "wa", + "tint": "#25d366", + "badge": 14, + "note": "4 chats, 2 business", + }, + { + "key": "app-x", + "app": "X", + "mono": "X", + "tint": "#3b3b42", + "badge": 9, + "note": "Mentions and 2 DMs", + }, + { + "key": "app-photos", + "app": "Photos", + "mono": "ph", + "tint": "#ff9f0a", + "badge": 0, + "note": "Yesterday's shots ready", + }, + { + "key": "app-calendar", + "app": "Calendar", + "mono": "16", + "tint": "#ff453a", + "badge": 2, + "note": "Site visit 09:15 Thursday", + }, ) #: Pending approvals waiting on the user. These are the lock screen's reason to @@ -5765,6 +5920,34 @@ def _demo_notifications() -> list[dict]: "agent": "inbox", "minutes": 54, }, + { + "key": "dec-refund", + "title": "Approve \u00a3120 refund to Ellis & Daughters", + "detail": "Duplicate charge on order ED-8841 \u00b7 confirmed by the bank feed", + "agent": "finance", + "minutes": 18, + }, + { + "key": "dec-hire", + "title": "Book the Thursday site survey", + "detail": "09:15 slot held \u00b7 confirms to Hargreaves and blocks your morning", + "agent": "diary", + "minutes": 42, + }, + { + "key": "dec-spend", + "title": "Renew the Twilio number for 12 months", + "detail": "\u00a38.50/mo \u00b7 the agent line drops if it lapses on the 28th", + "agent": "ops", + "minutes": 96, + }, + { + "key": "dec-post", + "title": "Publish the Northlight case study", + "detail": "Drafted and proofed \u00b7 goes to the site and LinkedIn", + "agent": "comms", + "minutes": 150, + }, { "key": "dec-deploy", "title": "Deploy taos-website build 412", @@ -5830,6 +6013,39 @@ def _demo_notifications() -> list[dict]: "tint": "#bf5af2", "minutes": 190, }, + { + "key": "prj-ellis", + "name": "Ellis & Daughters shopfit", + "note": "Survey booked \u00b7 materials list out for pricing", + "progress": 22, + "agents": 2, + "blocked": False, + "mono": "ED", + "tint": "#ff9f0a", + "minutes": 78, + }, + { + "key": "prj-voice", + "name": "Agent voice comms", + "note": "Spec only \u00b7 walkie-talkie over the volume keys", + "progress": 8, + "agents": 1, + "blocked": True, + "mono": "vc", + "tint": "#ff375f", + "minutes": 25, + }, + { + "key": "prj-splash", + "name": "Boot splash handover", + "note": "Wordmark holds to first paint \u00b7 fade tuning left", + "progress": 80, + "agents": 1, + "blocked": False, + "mono": "bs", + "tint": "#5e5ce6", + "minutes": 420, + }, { "key": "prj-northlight", "name": "Northlight pilot", From 8ccee09147bf4d5f66a46385335b25735da6cbfe Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 16 Sep 2026 20:41:43 +0000 Subject: [PATCH 03/33] The demo panels stop being invisible on the glass Jay, from the device: "still no projects, mailbox, phone or apps demo data". The panels were painting perfectly -- 354 `.ls-row` nodes in a DOM dump off the handset -- and none of them were on screen. The four panels are server-rendered `hidden`, and the view switcher only ever toggles `data-off`; it never clears `hidden`. The two panels that predate this row escape it because their own painters set `hidden` themselves (`notifsEl.hidden = !notifsEl.firstChild`). Mine had nobody doing that, so `.ls-feed > [data-view][hidden]` held them at display:none no matter which tab was selected. Cleared centrally in paintPanels rather than per-painter, so a panel with no rows still shows its "nothing here" card: hiding an empty panel would make it indistinguishable from one that failed to load. The reason 64 tests passed while this shipped is the more useful half. The harness created its panels VISIBLE, so it was not reproducing the markup the server emits, and every identity and content assertion was true of a subtree nobody could see. The harness now builds them hidden exactly as _lock_head_html() does, and two tests assert the panel is shown -- both go red with the fix reverted. Docs-Reviewed: a display fix inside the lock screen. No route, flag, schema or API surface changed; changelog.d/3106-lock-demo-panels.md already describes the panels as user-visible behaviour. --- tests/test_lock_demo_panels.py | 37 ++++++++++++++++++++++++++++++++++ tinyagentos/routes/auth.py | 16 +++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/test_lock_demo_panels.py b/tests/test_lock_demo_panels.py index 0cd3addd9..c702a86a3 100644 --- a/tests/test_lock_demo_panels.py +++ b/tests/test_lock_demo_panels.py @@ -419,6 +419,13 @@ def test_the_flags_on_serve_the_panels(self, monkeypatch): phone: makeNode("div"), mailbox: makeNode("div"), apps: makeNode("div"), projects: makeNode("div"), decisions: makeNode("div") }; +// Server-rendered HIDDEN, exactly as _lock_head_html() emits them. The harness +// used to create them visible, which is why 64 tests passed while every panel +// was display:none on the real glass. +panelEls.phone.hidden = true; +panelEls.mailbox.hidden = true; +panelEls.apps.hidden = true; +panelEls.projects.hidden = true; // The decisions container lives INSIDE the alerts panel on the real page, so // the harness gives it the same home -- otherwise the attach/detach the // painter performs would have nothing to attach to and would silently no-op. @@ -514,6 +521,8 @@ def test_the_flags_on_serve_the_panels(self, monkeypatch): out.__clocks = panelClocks.length; // Where the decisions container actually IS. Painted into a detached node it // would be correct, complete and invisible. + out.__hidden = {}; + for (var h in panelEls) out.__hidden[h] = !!panelEls[h].hidden; out.__decisions_parented = panelEls.decisions.parent === notifsEl; out.__decisions_first = notifsEl.children[0] === panelEls.decisions; return out; @@ -823,6 +832,34 @@ def test_the_progress_bar_keeps_its_node_so_it_animates_from_where_it_was(self): assert before["pct"] == "88" and after["pct"] == "93" assert before["id"] == after["id"] + def test_painting_a_panel_un_hides_it(self): + """The bug that reached the glass: 354 rows in the DOM and nothing + visible. + + The panels are server-rendered `hidden`, and the view switcher only + toggles `data-off` -- it never clears `hidden`. The two older panels + escape it because their own painters set `hidden` themselves. These + four had nobody doing it, so `.ls-feed > [data-view][hidden]` held them + at display:none whichever tab was selected. Every content and identity + assertion in this file passed throughout, because the harness had been + creating the panels VISIBLE -- it was not reproducing the markup. + """ + one, = _run([_payload()]) + for panel in ("phone", "mailbox", "apps", "projects"): + assert one["__hidden"][panel] is False, ( + f"{panel} is still hidden after being painted -- " + "it will render nothing on the device" + ) + + def test_an_empty_panel_is_shown_rather_than_hidden(self): + """A panel with no rows must still render its "nothing here" card. The + lazy fix for the above -- hide when empty, show when not -- would make + an empty panel vanish, which is the state that is hardest to tell from + a failure to load.""" + one, = _run([_payload(phone=[])]) + assert one["__hidden"]["phone"] is False + assert _keys(one, "phone") == ["empty"] + def test_decisions_sit_at_the_top_of_the_alerts_panel(self): """Not a tab of their own: Jay put them in alerts for quick answering. Painted into a detached container they would be correct, complete and diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index 23ddc2c27..0a6749d0c 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -3284,6 +3284,22 @@ def _lock_tail_html() -> str: function paintPanels(data) { lastPanels = data || {}; + // Clear the markup's `hidden` on every panel we paint. + // + // The panels are SERVER-RENDERED HIDDEN, and the view switcher only ever + // toggles `data-off` -- it never touches `hidden`. The two panels that + // predate this row get away with it because their own painters set + // `hidden` themselves (notifsEl.hidden = !notifsEl.firstChild). These + // four had nobody doing that, so `.ls-feed > [data-view][hidden]` kept + // them at display:none no matter which tab was selected: fully painted, + // 354 rows in the DOM, and invisible on the glass. + // + // Cleared here rather than per-painter so a panel showing its "nothing + // here" card is still shown -- an empty panel the user selected must + // render its empty state, not vanish. + for (var p in panelEls) { + if (panelEls[p] && p !== "decisions") panelEls[p].hidden = false; + } paintPhone(data.phone || []); paintMailbox(data.mailbox || []); paintApps(data.apps || []); From 3104fc3d906e1193fe432480b4934a36c5e32b16 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 16 Sep 2026 20:53:31 +0000 Subject: [PATCH 04/33] Alerts stops duplicating the mailbox, phone and apps panels Jay, from the glass: "the alerts category has some of the old notifications that need moving into the correct categories". The notification stacks predate the panels and were written when Alerts was the only place anything could go. Once phone, mailbox and apps existed, four of the five stacks were the same content twice: the mail stack repeated the mailbox, the X and SMS stacks repeated the mailbox, the phone stack repeated the missed calls, and the reddit stack repeated an app tile. Alerts now carries only what nothing else can: agent progress, system and update notices, a sign-in warning, and the nightly backup. The three items that existed ONLY in the old stacks were moved rather than dropped -- the Liverpool ticket ballot is a mailbox row, and the Reddit reply and X post count are the notes on their app tiles. Also, spacing Jay asked for in the same breath. The alerts gap goes 12px -> 18px: a collapsed stack carries 13px of padding for the cards peeking behind it, so a single-item alert sat visibly tighter than a stack did. And .ls-decisions had NO rule at all -- the pending-decision cards rely on a flex gap like every other list here, so they stacked flush against each other at the very top of the panel. Docs-Reviewed: demo content and CSS only. No route, flag or schema changed; changelog.d/3106-lock-demo-panels.md still describes this behaviour. --- tinyagentos/routes/auth.py | 89 ++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index 0a6749d0c..7e34ccadd 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -804,9 +804,24 @@ def count(self, key: str) -> int: Pressing a stack fans it out in place. That is ALL a press does: this screen renders before sign-in, so there is nothing here to open into. */ .ls-notifs { - display: flex; flex-direction: column; align-items: center; gap: 12px; + /* 18px, not 12: Jay, from the glass -- "the alert cards/banners need a little + space between eachother vertically". A collapsed stack also carries 13px of + padding-bottom for the cards peeking out behind it, so at 12px a + single-item alert (which has nothing peeking) sat visually tighter against + its neighbour than a stack did. */ + display: flex; flex-direction: column; align-items: center; gap: 18px; width: 100%; align-self: stretch; } +/* The pending-decision list at the head of the alerts panel. It had NO rule at + all, so its .ls-row cards -- which rely on a flex gap like every other list + on this screen -- stacked flush against each other with nothing between + them. It is the first thing in the panel, so that was the tightest spot on + the screen. */ +.ls-decisions { + display: flex; flex-direction: column; align-items: center; gap: 10px; + width: 100%; +} +.ls-decisions:empty { display: none; } .ls-notif-group { position: relative; width: 100%; max-width: var(--ls-card-w); @@ -5477,53 +5492,41 @@ async def lock_weather(request: Request): #: glyph for that source. _DEMO_NOTIFICATIONS: tuple[dict, ...] = ( { - "source": "mail", - "app": "Mail", - "glyph": "mail", - "tint": "#2f6fd0", - "items": ( - (12, "Hargreaves & Co", "Re: Thursday's site visit — 09:15 works for us. I'll bring the revised drawings."), - (74, "Companies House", "Your confirmation statement is due on 3 October."), - (221, "Liverpool FC", "Your ticket ballot result for Newcastle (H) is ready to view."), - ), - }, - { - "source": "x", - "app": "X", - "mono": "X", - "tint": "#3b3b42", + "source": "agent", + "app": "Agents", + "mono": "ta", + "tint": "#4c9aff", "items": ( - (8, "@marcus_dev mentioned you", "what's the actual memory floor for running this on a 4GB board?"), - (96, "12 posts from people you follow", "including 3 about on-device inference"), + (6, "Accountant finished reconciling", "September invoices are matched. 2 need your eye."), + (52, "Social Media Manager posted", "3 scheduled posts went out this morning."), ), }, { - "source": "reddit", - "app": "Reddit", - "mono": "r", - "tint": "#ff4500", + "source": "system", + "app": "System", + "mono": "sy", + "tint": "#8e8e93", "items": ( - (34, "r/selfhosted · 47 upvotes", "Someone replied to your comment on “Running an agent OS on a single board”."), - (150, "r/LocalLLaMA", "Today's discussion thread is up."), + (23, "Battery health check passed", "Capacity 94%. Next check in 30 days."), + (140, "taOS updated to build 412", "Lock screen panels and the new Projects view."), ), }, { - "source": "phone", - "app": "Phone", - "glyph": "phone", - "tint": "#34c759", + "source": "security", + "app": "Security", + "mono": "se", + "tint": "#ff9f0a", "items": ( - (41, "Missed call", "2 missed calls"), + (88, "New sign-in on the desktop app", "From your usual network. Tap if this was not you."), ), }, { - "source": "sms", - "app": "Messages", - "glyph": "sms", - "tint": "#25c05d", + "source": "backup", + "app": "Backup", + "mono": "bk", + "tint": "#30d158", "items": ( - (19, "Sam", "are you still alright for Sunday?"), - (310, "O2", "You've used 80% of your data allowance this month."), + (300, "Nightly backup completed", "412 MB in 41s. Nothing skipped."), ), }, ) @@ -5735,6 +5738,18 @@ def _demo_notifications() -> list[dict]: "tint": "#0a66c2", "unread": True, }, + { + "key": "mail-lfc", + "source": "mail", + "app": "Mail", + "who": "Liverpool FC", + "subject": "Ticket ballot result: Newcastle (H)", + "preview": "Your ballot result is ready to view.", + "minutes": 68, + "glyph": "mail", + "tint": "#2f6fd0", + "unread": True, + }, { "key": "mail-companies-house", "source": "mail", @@ -5863,7 +5878,7 @@ def _demo_notifications() -> list[dict]: "mono": "r", "tint": "#ff4500", "badge": 12, - "note": "r/selfhosted replies", + "note": "Reply on “Running an agent OS”", }, { "key": "app-bank", @@ -5898,7 +5913,7 @@ def _demo_notifications() -> list[dict]: "mono": "X", "tint": "#3b3b42", "badge": 9, - "note": "Mentions and 2 DMs", + "note": "12 posts from people you follow", }, { "key": "app-photos", From af172fe08cfc2f5febce45077f14c502c47fed38 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 16 Sep 2026 21:17:06 +0000 Subject: [PATCH 05/33] Hold the power key for the power menu Jay: "Power button tap screen on/off, hold for 1.5/2 seconds menu appears". Contents are his too -- Power off, Restart, Stop all agents, Screenshot, Emergency call -- with "stop all agents and emergency call needs confirmation". The menu is reachable BEFORE sign-in, as holding the physical key always was. Power off and Restart therefore add nothing the hardware did not already allow. "Stop all agents" genuinely does add something, which is why it confirms, and why the endpoint acts on a closed list of five verbs rather than on whatever it is sent. A PUSH, NOT A POLL. The key is a physical button, so the menu has to be up by the time the thumb lifts; this screen's fastest poll is 3s and its panels are 15 minutes. sway posts to /auth/lock-power-menu on loopback and the page hears it over an SSE stream that carries UI signals only -- no content, because it renders pre-auth and must never become a second way to read anything. The controller cannot power the phone off itself: it runs as `taos`, whose logind session is manager-early, and logind answers "challenge" to that user. A polkit rule granting those two actions was written, installed and MEASURED NOT TO FIRE -- a probe rule logging unconditionally on every action produced zero hits, so the rules were not being consulted at all. Rather than keep guessing at someone else's policy engine, the privileged step is a root systemd path unit reading a verb from /run/taos-power/request, where it can be read in full. /etc/sudoers here carries no includedir, so a sudoers drop-in would have been silently inert -- the "installed but never fires" failure this repo keeps meeting. Both arms of that helper were tested on the device: an unknown verb is refused and logged, and a valid verb dispatches, with the real systemctl calls swapped for a log line so the handset did not reboot mid-session. Screenshot is included and currently fails -- grim cannot capture this compositor. It reports the error text rather than a bare failure, because that string is the difference between a bug report and a shrug, and it is also the reason there is still no way to get a picture of this screen. Docs-Reviewed: the three new routes are console-only (_request_is_console) and unreachable with an agent token, so the agent-facing surface in docs/agent-coordination.md is unchanged. They join EXEMPT_PATHS for the same reason the other lock endpoints do: this screen renders before sign-in. User-visible behaviour is in changelog.d/3107-lock-power-menu.md. --- changelog.d/3107-lock-power-menu.md | 7 + tests/test_lock_power_menu.py | 235 ++++++++++++++++ tinyagentos/auth_middleware.py | 2 +- tinyagentos/routes/auth.py | 403 +++++++++++++++++++++++++++- 4 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 changelog.d/3107-lock-power-menu.md create mode 100644 tests/test_lock_power_menu.py diff --git a/changelog.d/3107-lock-power-menu.md b/changelog.d/3107-lock-power-menu.md new file mode 100644 index 000000000..21cce6c4e --- /dev/null +++ b/changelog.d/3107-lock-power-menu.md @@ -0,0 +1,7 @@ +- Lock screen: holding the power key for 1.5s opens a power menu with Power + off, Restart, Stop all agents, Screenshot and Emergency call. A short tap + still toggles the screen. +- Lock screen: "Stop all agents" and "Emergency call" ask for confirmation + before acting. +- Handset: double-tapping the dark screen wakes it. A single stray touch no + longer does. diff --git a/tests/test_lock_power_menu.py b/tests/test_lock_power_menu.py new file mode 100644 index 000000000..1bae42881 --- /dev/null +++ b/tests/test_lock_power_menu.py @@ -0,0 +1,235 @@ +"""The lock screen's power menu: hold the power key, choose, confirm. + +Jay's spec, verbatim: "Power button tap screen on/off, hold for 1.5/2 seconds +menu appears". He then chose the contents -- Power off, Restart, Stop all +agents, Screenshot, Emergency call -- and added "stop all agents and emergency +call needs confirmation". + +THE THING TO KEEP HOLD OF WHILE READING THIS FILE: **the menu is reachable +before sign-in**. Holding the physical key already powered the phone off from +the lock screen, so Power off and Restart add nothing the hardware did not have. +"Stop all agents" genuinely does add something, which is why it confirms, and +why the set of verbs the endpoint will act on is a closed list rather than +anything the caller sends. + +The privileged half is NOT here: the controller runs as `taos` and logind +answers "challenge" to that user, so it drops a verb in /run/taos-power/request +and a root systemd path unit acts on it. That helper was tested on the device +with both a negative control (an unknown verb is refused and logged) and a +positive one (a valid verb dispatches, with the real systemctl calls swapped +for a log line so the phone did not reboot mid-session). +""" +from __future__ import annotations + +import asyncio +import json + +import pytest + +import tinyagentos.routes.auth as auth +from tinyagentos.auth_middleware import EXEMPT_PATHS + + +class _Req: + """Enough of a Request for the handlers under test.""" + + def __init__(self, body=None): + self._body = body or {} + self.app = type("App", (), {"state": type("S", (), {})()})() + + async def json(self): + return self._body + + async def is_disconnected(self): + return True + + +def _call(coro): + return asyncio.run(coro) + + +def _body(resp): + return json.loads(bytes(resp.body)) + + +class TestTheConsoleGate: + """Every one of these is reachable with no session, so console-only is the + entire perimeter.""" + + @pytest.mark.parametrize( + "name, args", + [("lock_events", ()), ("lock_power_menu", ()), ("lock_power_action", ())], + ) + def test_a_non_console_request_is_refused(self, monkeypatch, name, args): + monkeypatch.setattr(auth, "_request_is_console", lambda _r: False) + resp = _call(getattr(auth, name)(_Req({"action": "poweroff"}), *args)) + assert resp.status_code == 403, name + + def test_all_three_are_exempt_from_auth(self): + """They render and fire before sign-in, so a session gate would make + the menu unreachable exactly when it is needed. /auth/lock-stats + shipped without this once and 401'd on the glass.""" + for path in ("/auth/lock-events", "/auth/lock-power-menu", + "/auth/lock-power-action"): + assert path in EXEMPT_PATHS, path + + +class TestTheActionIsAClosedSet: + def test_an_unlisted_action_is_refused(self, monkeypatch): + """Not "ignored", refused. This endpoint is the software half of a + privileged path, and the caller is a page on a pre-auth screen.""" + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + resp = _call(auth.lock_power_action(_Req({"action": "rm -rf /"}))) + assert resp.status_code == 400 + + def test_an_absent_action_is_refused(self, monkeypatch): + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + assert _call(auth.lock_power_action(_Req({}))).status_code == 400 + + def test_the_listed_actions_are_exactly_the_five_jay_chose(self): + assert set(auth._POWER_ACTIONS) == { + "poweroff", "reboot", "stop-agents", "screenshot", "emergency", + } + + @pytest.mark.parametrize("verb", ["poweroff", "reboot"]) + def test_a_power_verb_is_written_for_the_root_helper(self, monkeypatch, tmp_path, verb): + """The controller cannot power the phone off itself. It writes the verb + and something privileged reads it -- so what lands in that file IS the + contract, and it must be the bare verb with nothing else in it.""" + target = tmp_path / "request" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(target)) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + resp = _call(auth.lock_power_action(_Req({"action": verb}))) + assert resp.status_code == 200 + assert target.read_text() == verb + + def test_the_request_is_renamed_into_place_not_written_in_place( + self, monkeypatch, tmp_path + ): + """The watcher fires on the path EXISTING, so a half-written file could + be read as a verb that was never finished. Asserted by leaving no + partial behind.""" + target = tmp_path / "request" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(target)) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + _call(auth.lock_power_action(_Req({"action": "reboot"}))) + assert not (tmp_path / "request.part").exists() + assert [p.name for p in tmp_path.iterdir()] == ["request"] + + def test_an_unwritable_drop_box_is_reported_not_swallowed( + self, monkeypatch, tmp_path + ): + """A power button that silently does nothing is worse than one that + says it failed.""" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(tmp_path / "nope" / "request")) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + resp = _call(auth.lock_power_action(_Req({"action": "poweroff"}))) + assert resp.status_code == 503 + assert "detail" in _body(resp) + + def test_emergency_says_there_is_no_dialer_rather_than_pretending( + self, monkeypatch + ): + """There is no telephony stack on this handset. A menu entry that + silently does nothing in an emergency is the worst possible version of + this feature.""" + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + got = _body(_call(auth.lock_power_action(_Req({"action": "emergency"})))) + assert got["ok"] is False + assert got["demo"] is True + assert "dialer" in got["detail"].lower() + + def test_stop_agents_without_an_orchestrator_is_a_503(self, monkeypatch): + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + resp = _call(auth.lock_power_action(_Req({"action": "stop-agents"}))) + assert resp.status_code == 503 + + def test_stop_agents_drains_the_same_way_the_shutdown_hook_does(self, monkeypatch): + """Same orchestrator call as /api/system/prepare-shutdown. Two paths + that both claim to stop agents must not quietly do different things.""" + seen = {} + + class Orch: + async def prepare(self, scope, reason): + seen["scope"] = scope + seen["reason"] = reason + return {"drained": 3} + + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + req = _Req({"action": "stop-agents"}) + req.app.state.orchestrator = Orch() + got = _body(_call(auth.lock_power_action(req))) + assert got["ok"] is True + assert seen["scope"] == "all" + assert got["report"] == {"drained": 3} + + +class TestThePushChannel: + def test_holding_the_key_reaches_an_open_listener(self, monkeypatch): + """The menu must be up by the time the thumb lifts, so this is a push. + Delivery is COUNTED rather than assumed: "sent to nobody" and "sent" + are the same silence otherwise.""" + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + queue: asyncio.Queue = asyncio.Queue(maxsize=8) + auth._LOCK_EVENT_WAITERS.add(queue) + try: + got = _body(_call(auth.lock_power_menu(_Req()))) + assert got["delivered"] == 1 + assert queue.get_nowait() == "power-menu" + finally: + auth._LOCK_EVENT_WAITERS.discard(queue) + + def test_with_nobody_listening_it_reports_zero_rather_than_failing( + self, monkeypatch + ): + """The negative arm. A press with the screen asleep and no page open is + not an error, but it must be distinguishable from a delivered one.""" + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + auth._LOCK_EVENT_WAITERS.clear() + got = _body(_call(auth.lock_power_menu(_Req()))) + assert got["delivered"] == 0 + + def test_a_dead_listener_is_dropped_without_losing_the_event_for_others(self): + """One wedged page must not swallow the power key for the rest.""" + auth._LOCK_EVENT_WAITERS.clear() + full: asyncio.Queue = asyncio.Queue(maxsize=1) + full.put_nowait("filler") # now full: put_nowait will raise + live: asyncio.Queue = asyncio.Queue(maxsize=8) + auth._LOCK_EVENT_WAITERS.add(full) + auth._LOCK_EVENT_WAITERS.add(live) + try: + assert auth._push_lock_event("power-menu") == 1 + assert live.get_nowait() == "power-menu" + assert full not in auth._LOCK_EVENT_WAITERS + finally: + auth._LOCK_EVENT_WAITERS.clear() + + +class TestTheMenuOnTheGlass: + """The page half, read out of the served script rather than re-typed.""" + + def test_every_item_jay_chose_is_in_the_menu(self): + js = auth._LOCK_SCREEN_SCRIPT + for label in ("Power off", "Restart", "Stop all agents", + "Screenshot", "Emergency call"): + assert '"%s"' % label in js, label + + def test_the_two_he_asked_to_guard_are_the_two_that_confirm(self): + """Read off POWER_ITEMS, whose last field is the confirm flag, so this + tracks the real table rather than a copy of it.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("var POWER_ITEMS") + table = js[start:js.index("];", start)] + rows = [r for r in table.split("[") if '"' in r and "," in r] + confirming = [r.split('"')[1] for r in rows if r.rstrip(" ],\n").endswith("true")] + assert set(confirming) == {"Stop all agents", "Emergency call"}, confirming + + def test_the_page_subscribes_to_the_push_channel(self): + assert 'EventSource("/auth/lock-events")' in auth._LOCK_SCREEN_SCRIPT + assert 'addEventListener("power-menu"' in auth._LOCK_SCREEN_SCRIPT + + def test_the_sheet_exists_in_the_markup_and_is_reachable_by_name(self): + html = auth._lock_head_html() if hasattr(auth, "_lock_head_html") else "" + page = auth._LOCK_SCREEN_SCRIPT + assert 'if (name === "power")' in page, "openSheet cannot find the power sheet" + del html # the sheet is emitted outside the head fragment diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 36af18947..d2cba8abc 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -22,7 +22,7 @@ # console (see auth.is_console_origin) and throttles per user on top of that. # Note /auth/pin (set/clear a PIN) is deliberately absent from this set: those # require a live session and must stay gated here. -EXEMPT_PATHS = {"/auth/login", "/auth/pin-login", "/auth/osk.js", "/auth/pin-panel.js", "/auth/lock-screen.js", "/auth/lock-widgets", "/auth/lock-weather", "/auth/lock-notifications", "/auth/lock-stats", "/auth/lock-panels", "/auth/setup", "/auth/status", "/auth/me", "/auth/complete", "/auth/lock", "/api/health", "/api/version", "/setup", "/setup/complete", "/redeem", "/api/desktop/browser/push/vapid-public-key", "/api/desktop/browser/proxy-config", "/sw.js", "/desktop", "/desktop/index.html", "/chat-pwa", "/app.html", "/manifest", "/api/agents/registry/pubkey", "/api/share/destinations"} +EXEMPT_PATHS = {"/auth/login", "/auth/pin-login", "/auth/osk.js", "/auth/pin-panel.js", "/auth/lock-screen.js", "/auth/lock-widgets", "/auth/lock-weather", "/auth/lock-notifications", "/auth/lock-stats", "/auth/lock-panels", "/auth/lock-events", "/auth/lock-power-menu", "/auth/lock-power-action", "/auth/setup", "/auth/status", "/auth/me", "/auth/complete", "/auth/lock", "/api/health", "/api/version", "/setup", "/setup/complete", "/redeem", "/api/desktop/browser/push/vapid-public-key", "/api/desktop/browser/proxy-config", "/sw.js", "/desktop", "/desktop/index.html", "/chat-pwa", "/app.html", "/manifest", "/api/agents/registry/pubkey", "/api/share/destinations"} # Registry feed endpoints accept EITHER an admin session OR a registry JWT. # When a Bearer token is present for these paths the request bypasses the diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index 7e34ccadd..34367edd4 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -14,7 +14,13 @@ import httpx from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.responses import ( + HTMLResponse, + JSONResponse, + RedirectResponse, + Response, + StreamingResponse, +) from tinyagentos.auth import ( PIN_MAX_LEN, PIN_MIN_LEN, @@ -822,6 +828,50 @@ def count(self, key: str) -> int: width: 100%; } .ls-decisions:empty { display: none; } + +/* THE POWER MENU. Big targets: this is reached by feel, often in the dark, + sometimes in a hurry, and it is the one surface here where picking the wrong + row costs something. */ +.ls-power-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 0 6px; } +.ls-power-item { + display: flex; align-items: center; gap: 13px; + width: 100%; padding: 14px 15px; border: 0; border-radius: 18px; + font: inherit; font-size: 16px; font-weight: 600; text-align: left; + color: #fff; background: rgba(255,255,255,0.09); +} +.ls-power-item:focus-visible { outline: 3px solid #4c9aff; outline-offset: 2px; } +.ls-power-item[data-danger="1"] { color: #ff6b6b; } +.ls-power-glyph { + flex: none; width: 30px; height: 30px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; + font-size: 15px; background: rgba(255,255,255,0.10); +} +.ls-power-note { + display: block; margin-top: 2px; + font-size: 12px; font-weight: 500; color: rgba(255,255,255,0.55); +} +/* The confirm step for the two Jay asked to guard. It REPLACES the row rather + than opening a second dialog: a nested modal on a lock screen is a place to + get lost, and the question should sit where the answer was given. */ +.ls-power-confirm { + display: flex; flex-direction: column; gap: 8px; + padding: 13px 15px; border-radius: 18px; + background: rgba(255,59,48,0.14); +} +.ls-power-confirm-q { font-size: 14px; font-weight: 600; color: #fff; } +.ls-power-confirm-note { font-size: 12px; line-height: 1.35; color: rgba(255,255,255,0.68); } +.ls-power-confirm-row { display: flex; gap: 8px; margin-top: 2px; } +.ls-power-confirm-row button { + flex: 1; padding: 10px; border: 0; border-radius: 12px; + font: inherit; font-size: 14px; font-weight: 600; + background: rgba(255,255,255,0.12); color: #fff; +} +.ls-power-confirm-row button[data-go="1"] { background: rgba(255,59,48,0.34); color: #ffb3ad; } +.ls-power-result { + padding: 11px 15px; border-radius: 14px; + background: rgba(255,255,255,0.08); + font-size: 13px; line-height: 1.4; color: rgba(255,255,255,0.78); +} .ls-notif-group { position: relative; width: 100%; max-width: var(--ls-card-w); @@ -1919,6 +1969,27 @@ def _lock_tail_html() -> str: + + +