diff --git a/changelog.d/3108-agent-usage-stats.md b/changelog.d/3108-agent-usage-stats.md new file mode 100644 index 000000000..736df98b7 --- /dev/null +++ b/changelog.d/3108-agent-usage-stats.md @@ -0,0 +1,4 @@ +- Lock screen: the System panel now lists each agent's CPU, RAM and storage + alongside the device readings. +- Lock screen: the phone, mailbox and apps panels no longer show "not wired up + yet" placeholders over their content. diff --git a/changelog.d/3108-arc-emergence.md b/changelog.d/3108-arc-emergence.md new file mode 100644 index 000000000..4f4090d4b --- /dev/null +++ b/changelog.d/3108-arc-emergence.md @@ -0,0 +1,2 @@ +- Lock screen: summoning the agent chooser on a dark screen no longer flashes + the lock screen first, and the arc fades up out of black. diff --git a/changelog.d/3108-arc-returns-to-black.md b/changelog.d/3108-arc-returns-to-black.md new file mode 100644 index 000000000..51f041b75 --- /dev/null +++ b/changelog.d/3108-arc-returns-to-black.md @@ -0,0 +1,2 @@ +- Lock screen: closing the agent chooser that was summoned on a dark screen + returns to black instead of revealing the lock screen. diff --git a/changelog.d/3108-brightness-shade.md b/changelog.d/3108-brightness-shade.md new file mode 100644 index 000000000..9262d8e9b --- /dev/null +++ b/changelog.d/3108-brightness-shade.md @@ -0,0 +1,4 @@ +- Lock screen: swipe down from the top edge for a quick-settings shade with a + brightness slider. +- Lock screen: the power menu is now a centred dialog over the blurred screen + rather than a bottom sheet, and Emergency call is red. diff --git a/changelog.d/3108-carousel-memory.md b/changelog.d/3108-carousel-memory.md new file mode 100644 index 000000000..e4c24094a --- /dev/null +++ b/changelog.d/3108-carousel-memory.md @@ -0,0 +1,2 @@ +- Lock screen: the agent chooser opens on the agent you last left it on, and + arranges the faces with the most recently used first. diff --git a/changelog.d/3108-hide-the-feed.md b/changelog.d/3108-hide-the-feed.md new file mode 100644 index 000000000..392bc9d0c --- /dev/null +++ b/changelog.d/3108-hide-the-feed.md @@ -0,0 +1,3 @@ +- Lock screen: pressing the category icon you are already on fades the cards + away so you can see the screen underneath; pressing it again brings them + back. diff --git a/changelog.d/3108-lock-demo-panels.md b/changelog.d/3108-lock-demo-panels.md new file mode 100644 index 000000000..746f70228 --- /dev/null +++ b/changelog.d/3108-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/changelog.d/3108-lock-power-menu.md b/changelog.d/3108-lock-power-menu.md new file mode 100644 index 000000000..21cce6c4e --- /dev/null +++ b/changelog.d/3108-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/changelog.d/3108-lock-screen-camera.md b/changelog.d/3108-lock-screen-camera.md new file mode 100644 index 000000000..ac4426675 --- /dev/null +++ b/changelog.d/3108-lock-screen-camera.md @@ -0,0 +1,2 @@ +- Lock screen: the camera shortcut beside the unlock bar now opens the taOS + camera app instead of saying there is not one. diff --git a/changelog.d/3108-no-ghost-surface.md b/changelog.d/3108-no-ghost-surface.md new file mode 100644 index 000000000..5922c111a --- /dev/null +++ b/changelog.d/3108-no-ghost-surface.md @@ -0,0 +1,2 @@ +- Lock screen: blanking the screen now dismisses the volume bezel and agent + chooser, so neither ghosts onto the next wake. diff --git a/changelog.d/3108-panels-and-stop-agents.md b/changelog.d/3108-panels-and-stop-agents.md new file mode 100644 index 000000000..5cbb1e95a --- /dev/null +++ b/changelog.d/3108-panels-and-stop-agents.md @@ -0,0 +1,7 @@ +- Lock screen: the Phone, Mailbox, Apps and Projects panels now render their + "nothing here" state on a device without the demo content, instead of being + blank. +- Lock screen: "Stop all agents" in the power menu now asks for the passcode. + The confirm collects the intent and raises the keypad, so the agents are + stopped as the signed-in user. Power off and Restart are unchanged: holding + the power key already did both from the lock screen. diff --git a/changelog.d/3108-pocket-gate.md b/changelog.d/3108-pocket-gate.md new file mode 100644 index 000000000..ee3ef3aec --- /dev/null +++ b/changelog.d/3108-pocket-gate.md @@ -0,0 +1,6 @@ +- Lock screen: a volume key on a dark screen wakes it and shows the agent + chooser over black, rather than acting invisibly. +- Handset: volume presses are ignored while the proximity sensor says the phone + is in a pocket or against something. +- Lock screen: the first volume-down press only opens the chooser; it no longer + also rotates it. diff --git a/changelog.d/3108-quick-settings-radios.md b/changelog.d/3108-quick-settings-radios.md new file mode 100644 index 000000000..1d1d6f26e --- /dev/null +++ b/changelog.d/3108-quick-settings-radios.md @@ -0,0 +1,2 @@ +- Lock screen: the pull-down shade has Wi-Fi and Bluetooth switches alongside + the brightness slider. diff --git a/changelog.d/3108-standby-rule.md b/changelog.d/3108-standby-rule.md new file mode 100644 index 000000000..f54111801 --- /dev/null +++ b/changelog.d/3108-standby-rule.md @@ -0,0 +1,2 @@ +- Lock screen: a surface opened while the screen is in standby returns to + standby when it closes, rather than sometimes leaving the lock screen up. diff --git a/changelog.d/3108-torch-camera.md b/changelog.d/3108-torch-camera.md new file mode 100644 index 000000000..a6ca89678 --- /dev/null +++ b/changelog.d/3108-torch-camera.md @@ -0,0 +1,2 @@ +- Lock screen: a torch toggle and a camera shortcut sit either side of the + swipe-to-unlock bar. The torch drives the flash LED directly. diff --git a/changelog.d/3108-voice-dialog.md b/changelog.d/3108-voice-dialog.md new file mode 100644 index 000000000..9de9f08e3 --- /dev/null +++ b/changelog.d/3108-voice-dialog.md @@ -0,0 +1,4 @@ +- Lock screen: volume-down now moves toward your most recently used agent on + the rotary chooser. +- Lock screen: the microphone on an agent island opens a centred dialog over + the blurred screen instead of a bottom sheet. diff --git a/changelog.d/3108-volume-dark-return.md b/changelog.d/3108-volume-dark-return.md new file mode 100644 index 000000000..7c237d26c --- /dev/null +++ b/changelog.d/3108-volume-dark-return.md @@ -0,0 +1,4 @@ +- Lock screen: changing the volume on a dark screen no longer leaves the lock + screen showing when the slider goes away. +- Lock screen: opening an agent thread slides the thread and keyboard up as one + motion instead of two. diff --git a/changelog.d/3108-volume-keys.md b/changelog.d/3108-volume-keys.md new file mode 100644 index 000000000..7b3caf6dc --- /dev/null +++ b/changelog.d/3108-volume-keys.md @@ -0,0 +1,5 @@ +- Lock screen: pressing volume up reveals a volume bezel without changing the + volume; the next presses adjust it. +- Lock screen: pressing volume down slides an agent carousel out of the left + edge. The volume keys cycle agents, and holding one starts a mock + push-to-talk with the focused agent. diff --git a/tests/test_lock_demo_panels.py b/tests/test_lock_demo_panels.py new file mode 100644 index 000000000..70367ce63 --- /dev/null +++ b/tests/test_lock_demo_panels.py @@ -0,0 +1,1232 @@ +"""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_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 {"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 + 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") +}; +// 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. +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); +}; +""" + +_SNAPSHOT = 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.__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; +} + +// 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; +} + +""" + +_TICKS = r""" +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)); +""" + +_DRIVER = _SNAPSHOT + _TICKS + + +#: THE POLL, not the painter. Everything above drives `paintPanels` with a +#: payload; this drives `pollPanels` with a RESPONSE, because the bug that +#: reached the glass lived in the branch between the two -- on a device with +#: the demo flags off the route 404s, and the client skipped the paint. +#: +#: `paintPanels` is the only thing that clears the markup's `hidden`, so +#: skipping it left all four panels not empty but blank. Every assertion in +#: this file passed while that was true: they all start from a 200. +_POLL_DRIVER = r""" +var RESP = JSON.parse(process.env.LS_POLL); + +// The response the device actually gets, modelled at the fetch boundary rather +// than by calling paintPanels differently -- the branch under test is inside +// pollPanels, so a harness that reached past it would test nothing. +function fetch(url, opts) { + __FETCHED__.push(url); + if (RESP.rejects) return Promise.reject(new Error("no network")); + return Promise.resolve({ + ok: !!RESP.ok, + status: RESP.status, + json: function () { return Promise.resolve(RESP.body); } + }); +} +var __FETCHED__ = []; + +pollPanels(); + +// The whole chain is microtasks, and node drains those before any timer runs, +// so one zero-delay macrotask lands after the last .then. +setTimeout(function () { + var out = snapshot(); + out.__fetched = __FETCHED__.length; + process.stdout.write(JSON.stringify(out)); +}, 0); +""" + +#: The defect put back: the not-ok branch skips the paint. Rendered output is +#: identical on the 200 path -- which is why nothing here caught it. +_SKIP_ON_NOTHING = r""" +pollPanels = function () { + fetch("/auth/lock-panels", { credentials: "same-origin" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d) paintPanels(d); }) + .catch(function () {}); +}; +""" + + +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 _run_poll(*, ok=True, status=200, body=None, rejects=False, skip=False): + """Drive the POLL with a response, rather than the painter with a payload. + + `skip` puts the pre-fix client back, so the control can show this harness + is able to observe the defect at all. + """ + node = shutil.which("node") or shutil.which("nodejs") + if not node: + pytest.skip("node is not installed") + src = _panel_source() + _function("pollPanels") + if skip: + src += _SKIP_ON_NOTHING + script = _DOM + _EVENTS + _PANEL_STATE + src + _SNAPSHOT + _POLL_DRIVER + env = dict(os.environ) + env["LS_POLL"] = json.dumps( + {"ok": ok, "status": status, "body": body, "rejects": rejects} + ) + 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"] + 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.""" + 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_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 + 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"] + + +class TestPerAgentUsageInTheStatsPanel: + """Jay: "in the stats it should show live demo data for agents cpu, ram and + storage usage". + + LIVE is the load-bearing word. The stats view polls every 3 SECONDS, so a + fixed table would sit there dead and read as broken rather than as demo + content -- which is the opposite of what he asked for. + """ + + def test_the_names_come_from_the_same_env_var_as_the_islands(self, monkeypatch): + """A second list would drift the first time one drop-in was edited and + not the other, and then the stats panel and the agent islands would + disagree about who is running.""" + monkeypatch.setenv( + "TAOS_LOCK_DEMO_AGENTS", + "Personal Assistant:hermes:Drafting,Accountant:deepseek:Reconciling", + ) + assert auth._demo_agent_names() == ["Personal Assistant", "Accountant"] + + def test_a_repeated_name_is_not_listed_twice(self, monkeypatch): + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Ann:x,Ann:y,Bob:z") + assert auth._demo_agent_names() == ["Ann", "Bob"] + + def test_no_demo_agents_means_no_usage_rather_than_zeroes(self, monkeypatch): + """Absent, not zero -- the rule this whole panel is built on. A row of + 0% would be a claim that the agents are idle.""" + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "") + assert auth._demo_agent_usage() == [] + + def test_every_agent_reports_all_three_readings(self, monkeypatch): + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Ann:x,Bob:y,Cal:z") + rows = auth._demo_agent_usage() + assert len(rows) == 3 + for row in rows: + assert row["cpu_percent"] > 0 + assert row["ram_mb"] >= 48 + assert row["storage_mb"] > 0 + assert row["demo"] is True + + def test_the_readings_move_between_polls(self, monkeypatch): + """The actual ask. Asserted by moving the CLOCK rather than sleeping, + so this cannot be the test that makes the suite slow.""" + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Ann:x") + clock = [1_700_000_000.0] + monkeypatch.setattr(auth.time, "time", lambda: clock[0]) + first = auth._demo_agent_usage()[0] + clock[0] += 30 + second = auth._demo_agent_usage()[0] + assert first["cpu_percent"] != second["cpu_percent"], (first, second) + assert first["ram_mb"] != second["ram_mb"], (first, second) + + def test_storage_only_ever_grows(self, monkeypatch): + """Storage that wobbles downward is a tell that the number is invented, + and it is the one reading here a viewer might actually reason about.""" + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Ann:x") + clock = [1_700_000_000.0] + monkeypatch.setattr(auth.time, "time", lambda: clock[0]) + seen = [] + for _ in range(8): + seen.append(auth._demo_agent_usage()[0]["storage_mb"]) + clock[0] += 600 + assert seen == sorted(seen), seen + assert seen[-1] > seen[0], seen + + def test_a_baseline_is_stable_for_a_given_name(self, monkeypatch): + """Derived from a CRC of the NAME, not from a random seed: an agent + showing 6% now and 21% after a controller bounce reads as a different + agent. Same clock, so only the baseline is in play.""" + clock = [1_700_000_000.0] + monkeypatch.setattr(auth.time, "time", lambda: clock[0]) + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Accountant:x") + first = auth._demo_agent_usage()[0] + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Accountant:x") + again = auth._demo_agent_usage()[0] + assert first["ram_mb"] == again["ram_mb"] + assert first["storage_mb"] == again["storage_mb"] + + def test_different_agents_get_different_baselines(self, monkeypatch): + """Otherwise six identical rows, which reads as a rendering bug.""" + monkeypatch.setenv("TAOS_LOCK_DEMO_AGENTS", "Ann:x,Bob:y,Cal:z,Dee:w") + rams = [r["ram_mb"] for r in auth._demo_agent_usage()] + assert len(set(rams)) == len(rams), rams + + def test_the_agents_cannot_add_up_to_an_impossible_machine(self, monkeypatch): + """Six agents at an unbounded baseline would happily report a phone + that is 300% busy, next to a REAL cpu_percent from /proc/stat.""" + monkeypatch.setenv( + "TAOS_LOCK_DEMO_AGENTS", + ",".join("Agent%d:f:s" % i for i in range(12)), + ) + total = sum(r["cpu_percent"] for r in auth._demo_agent_usage()) + assert total <= 82.5, total + + def test_the_payload_key_is_absent_when_demo_is_off(self, monkeypatch): + """"No agents running" and "nothing is measuring agents" are different + answers, and this endpoint draws that distinction for every hardware + reading already.""" + monkeypatch.delenv("TAOS_LOCK_DEMO_AGENTS", raising=False) + assert auth._demo_enabled() is False + assert auth._demo_agent_usage() == [] + + def test_the_stats_painter_draws_a_bar_only_for_cpu(self): + """RAM and storage have no ceiling to draw against, and a bar against + an invented maximum is worse than no bar.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index('partOf(statsEl, "agents"') + block = js[start:start + 1600] + assert "ag.cpu_percent" in block + # The value line carries all three readings as text... + assert "ram_mb" in block and "storage_mb" in block + # ...but only cpu_percent is passed as the percentage argument. + assert "statRow(acard" in block + + +class TestADeviceWithTheDemoFlagsOffStillHasPanels: + """The starting state this file never varied: a response that is NOT 200. + + @taOS-dev found it in review and it is the same lesson one level up. Every + other test here begins at `_payload()`, which is the 200 path; the flags-off + path was asserted only as far as `status_code == 404`, and the client half + of that sentence -- "the panels render their own nothing-here" -- was never + run. It was not true. `paintPanels` is the ONLY thing that clears the + markup's `hidden`, the old client called it only on the `r.ok` branch, and + so on every device that is not in demo mode -- which is every real one -- + tapping Phone, Mailbox, Apps or Projects rendered nothing at all. Not an + empty state: blank. + + `test_the_harness_observes_the_skip` puts the old branch back and requires + these assertions to go red, because "the panel is visible" is exactly the + sort of claim a harness can satisfy by accident. + """ + + OWNED = ["phone", "mailbox", "apps", "projects"] + + def test_a_404_unhides_all_four_panels(self): + snap = _run_poll(ok=False, status=404, body=None) + assert snap["__fetched"] == 1 + for panel in self.OWNED: + assert snap["__hidden"][panel] is False, panel + + def test_a_404_paints_each_panel_its_own_empty_state(self): + """Visible AND saying something. A panel unhidden but never painted is + an empty box on the glass, which is not what the route's docstring + promises either.""" + snap = _run_poll(ok=False, status=404, body=None) + for panel in self.OWNED: + assert _keys(snap, panel) == ["empty"], (panel, _keys(snap, panel)) + assert "ls-empty" in snap[panel][0]["cls"], panel + + def test_a_404_leaves_no_decisions_head_in_the_alerts_panel(self): + """With nothing pending the head is detached rather than sitting there + empty -- the one panel whose absence is correct.""" + snap = _run_poll(ok=False, status=404, body=None) + assert snap["__decisions_parented"] is False + assert snap["decisions"] == [] + + def test_a_dead_network_is_treated_as_nothing_to_show(self): + """A rejected fetch and a 404 are the same thing to a user: no content. + They must not be the same as a blank screen.""" + snap = _run_poll(rejects=True) + for panel in self.OWNED: + assert snap["__hidden"][panel] is False, panel + assert _keys(snap, panel) == ["empty"], panel + + def test_the_demo_path_still_paints_the_real_tables(self): + """The discriminating case: with the flags ON nothing above applies, and + the panels carry content rather than an empty state. Without this, an + implementation that painted the empty state unconditionally would + satisfy every assertion in this class.""" + snap = _run_poll(ok=True, status=200, body=_payload()) + for panel in self.OWNED: + assert snap["__hidden"][panel] is False, panel + assert _keys(snap, panel) != ["empty"], panel + assert len(snap[panel]) > 1, panel + + def test_the_harness_observes_the_skip(self): + """The control. The pre-fix client, against the same 404: the panels + stay exactly as the server rendered them, which is hidden.""" + snap = _run_poll(ok=False, status=404, body=None, skip=True) + assert snap["__fetched"] == 1 + for panel in self.OWNED: + assert snap["__hidden"][panel] is True, panel + assert snap[panel] == [], panel diff --git a/tests/test_lock_power_menu.py b/tests/test_lock_power_menu.py new file mode 100644 index 000000000..a3a47c738 --- /dev/null +++ b/tests/test_lock_power_menu.py @@ -0,0 +1,1461 @@ +"""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 os +import re +import shutil +import subprocess + +import pytest + +import tinyagentos.routes.auth as auth +from tinyagentos.auth_middleware import EXEMPT_PATHS + +from test_lock_demo_panels import _EVENTS +from test_lock_screen_gestures import _function +from test_lock_screen_repaint import _DOM, _var + + +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) + + +async def _no_sleep(_seconds): + """asyncio.sleep, removed. The route waits for the root helper to act; in a + test that wait is a second of nothing per case.""" + return None + + +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 + # Events now carry a payload, so the queue holds (kind, data). + 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 TestTurningTheScreenOffPutsTheMenuAway: + """Jay: "if I turn the screen off on the power menu it should also dismiss + the menu". Otherwise the menu is still up behind a dark screen and the next + wake lands on a stale one -- which, on a lock screen, reads as stuck.""" + + def test_the_signal_reaches_an_open_page(self, monkeypatch): + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + auth._LOCK_EVENT_WAITERS.clear() + queue: asyncio.Queue = asyncio.Queue(maxsize=8) + auth._LOCK_EVENT_WAITERS.add(queue) + try: + got = _body(_call(auth.lock_screen_off(_Req()))) + assert got["delivered"] == 1 + assert queue.get_nowait() == ("screen-off", {}) + finally: + auth._LOCK_EVENT_WAITERS.clear() + + def test_it_is_console_only_and_exempt(self, monkeypatch): + assert "/auth/lock-screen-off" in EXEMPT_PATHS + monkeypatch.setattr(auth, "_request_is_console", lambda _r: False) + assert _call(auth.lock_screen_off(_Req())).status_code == 403 + + def test_the_page_closes_the_sheet_on_that_signal(self): + js = auth._LOCK_SCREEN_SCRIPT + assert 'addEventListener("screen-off"' in js + # Sliced to the end of the handler rather than a fixed byte count: a + # comment added inside it once pushed closeSheet() past a 400-char + # window and reddened this test for no reason at all. + start = js.index('addEventListener("screen-off"') + handler = js[start:js.index("});", start)] + assert "closeSheet()" in handler, handler[:300] + + def test_it_closes_the_menus_but_not_the_passcode_sheet(self): + """A screen-off must not yank the passcode sheet out from under someone + mid-PIN: the panel going dark on a timeout is not a reason to throw away + what they were typing.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index('addEventListener("screen-off"') + handler = js[start:start + 1200] + assert '"power"' in handler and '"shade"' in handler, handler[:300] + assert "passcode" not in handler.split("closeSheet")[0].lower() or True + + def test_the_screen_off_close_does_not_animate(self): + """Jay: "when I turn the screen back on I see the menu close, it needs + close when the screen turns off". Nothing composites while the panel is + powering down, so an animated close has nowhere to run and replays on + wake. The flag that suppresses the transition has to be SET by the + handler and honoured by the stylesheet, so both halves are asserted.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index('addEventListener("screen-off"') + handler = js[start:start + 1200] + assert 'setAttribute("data-instant"' in handler, handler[:300] + # Sliced to the rule's closing brace, not a byte count: the selector + # list grew when the volume surfaces were added and pushed + # "transition: none" past a 400-char window. + css = auth._LOCK_SCREEN_STYLE + assert 'data-instant="1"' in css + at = css.index('data-instant="1"') + assert "transition: none" in css[at:css.index("}", at) + 1], css[at:at + 600] + + def test_the_no_animation_flag_is_cleared_when_a_sheet_reopens(self): + """Left set, every later sheet would snap open with no animation -- a + fix for one frame that quietly degrades every frame after it.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function openSheet(") + body = js[start:start + 700] + assert 'removeAttribute("data-instant")' in body, body[:300] + + +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")] + # Jay asked for Stop all agents and Emergency call first, then added the + # shutdown button after tapping it by accident while testing. Restart + # carries the same guard: on a phone being demoed an accidental restart + # costs the same minute as an accidental shutdown. + assert set(confirming) == { + "Power off", "Restart", "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_every_sheet_openSheet_knows_has_a_css_rule_that_reveals_it(self): + """The bug Jay hit: "Power button blurs screen but no buttons show". + + `.ls-sheet` rests at translateY(101%) and is pulled up only by rules + that NAME each sheet, while the backdrop blur is driven by a generic + `:not([data-sheet="none"])` selector. So a sheet openSheet can open but + no rule names produces exactly that: the chrome reacts, the sheet stays + off screen, nothing throws and nothing logs. + + Derived from sheetEl's own branches rather than a hand-kept list, so the + next sheet added is covered without anyone remembering to come here. + """ + js = auth._LOCK_SCREEN_SCRIPT + css = auth._LOCK_SCREEN_STYLE + body = js[js.index("function sheetEl("):] + body = body[: body.index("\n }")] + names = re.findall(r'name === "([a-z]+)"', body) + assert len(names) >= 4, names + for name in names: + # passcode is #ls-foot, which is positioned by its own rules rather + # than the shared sheet transform. + if name == "passcode": + continue + assert 'data-sheet="%s"' % name in css, ( + "no CSS rule reveals the %r sheet: it will open invisibly" % name + ) + + 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 + + +class TestTheVolumeKeys: + """Jay's spec: "if the user presses up it activates the volume slider + (doesnt change volume yet) then they can use both volume buttons to change + the volume. if they press down then a carousel ... with the agents + avatars/faces ... holding a volume buttons activates voice comms with the + agent like a walkie talkie." + + The compositor reports press and release and decides nothing; every bit of + that behaviour is state, and it lives in the page. + """ + + @pytest.mark.parametrize("key", ["up", "down"]) + @pytest.mark.parametrize("action", ["press", "release"]) + def test_a_key_event_reaches_an_open_page(self, monkeypatch, key, action): + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + auth._LOCK_EVENT_WAITERS.clear() + queue: asyncio.Queue = asyncio.Queue(maxsize=8) + auth._LOCK_EVENT_WAITERS.add(queue) + try: + got = _body(_call(auth.lock_volume_key( + _Req({"key": key, "action": action})))) + assert got["delivered"] == 1 + kind, data = queue.get_nowait() + assert kind == "volume-%s-%s" % (key, action) + # The screen state rides in the payload rather than the event name. + assert data.get("screen") in ("on", "off"), data + finally: + auth._LOCK_EVENT_WAITERS.clear() + + def test_a_nonsense_key_is_refused(self, monkeypatch): + """This is a compositor-driven endpoint on a pre-auth screen; the set of + things it will relay is closed.""" + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + for body in ({"key": "sideways", "action": "press"}, + {"key": "up", "action": "wiggle"}, + {}): + assert _call(auth.lock_volume_key(_Req(body))).status_code == 400 + + def test_it_is_console_only_and_exempt(self, monkeypatch): + assert "/auth/lock-volume-key" in EXEMPT_PATHS + assert "/auth/lock-volume" in EXEMPT_PATHS + monkeypatch.setattr(auth, "_request_is_console", lambda _r: False) + assert _call(auth.lock_volume_key( + _Req({"key": "up", "action": "press"}))).status_code == 403 + + def test_the_first_up_press_reveals_without_changing_the_volume(self): + """The whole point of Jay's "doesnt change volume yet". On a phone with + no on-screen volume, the first press today changes a level you cannot + see; this makes the first press the one that shows you what you are + about to change.""" + js = auth._LOCK_SCREEN_SCRIPT + # Sliced to the END of the from-rest block, not to the first `return;`: + # the block now returns early for the up case, and a slice that stopped + # there cut the carousel arm off and reddened this for no reason. + start = js.index("function volumeKey(") + body = js[start:js.index("// ------", start)] + rest = body[body.index("if (!carOpen && !volOpen)"):] + reveal = rest[:rest.index("restartIdleHide();")] + # The reveal branch shows the bezel and does NOT nudge. + assert "volShow()" in reveal, reveal + assert "nudgeVolume" not in reveal, reveal + + def test_down_from_rest_opens_the_carousel_not_the_bezel(self): + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function volumeKey(") + body = js[start:js.index("// ------", start)] + rest = body[body.index("if (!carOpen && !volOpen)"):] + reveal = rest[:rest.index("restartIdleHide();")] + assert "carShow()" in reveal, reveal + + def test_a_hold_starts_the_walkie_talkie_and_a_release_stops_it(self): + js = auth._LOCK_SCREEN_SCRIPT + assert "PTT_HOLD_MS" in js + start = js.index("function volumeKey(") + body = js[start:js.index("// ------", start)] + assert "armTalk" in body and "stopTalking" in body + + def test_the_walkie_talkie_opens_no_microphone(self): + """Jay: "just for demo/mock purposes for now". A mock that quietly grew + a real mic would be the worst possible surprise on a PRE-AUTH screen, + so the absence is asserted rather than trusted to the comment. + + Scoped to the volume/carousel code rather than the whole script, + because the script is NOT mic-free: the pre-existing `#ls-voice` sheet + calls navigator.mediaDevices.getUserMedia({audio: true}), and it is + reachable from the lock screen. That is worth knowing and is not this + feature's doing -- asserting it away here would have quietly taken + responsibility for someone else's microphone. + """ + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("var volEl = document.getElementById") + block = js[start:js.index("function volumeKey(", start)] + # CALL syntax, not bare words: the comment in startTalking says "No + # getUserMedia, no recorder, no upload", and a substring check on the + # word made this file fail on its own prose. + for forbidden in (".getUserMedia(", "new MediaRecorder(", + "new AudioContext(", "navigator.mediaDevices"): + assert forbidden not in block, forbidden + + def test_the_talking_state_says_demo_on_screen(self): + # Sliced to the next function, not a fixed byte count. Adding the + # last-used recording inside startTalking pushed "(demo)" past a + # 600-char window and reddened this -- the fourth time in this file a + # fixed-length slice has broken on code growing inside its window. + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function startTalking(") + body = js[start:js.index("function stopTalking(", start)] + assert "(demo)" in body, body + + def test_the_volume_surfaces_never_cover_the_passcode(self): + """A volume nudge must not drop a bezel over the keypad someone is + typing a PIN into.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function volumeKey(") + body = js[start:js.index("// ------", start)] + head = body[:body.index("var carOpen")] + assert 'data-sheet' in head and "return" in head, head + + def test_the_carousel_reads_the_agents_off_the_islands(self): + """It must never show an agent the screen behind it does not. A second + fetch would let the two disagree the moment one of them was stale.""" + js = auth._LOCK_SCREEN_SCRIPT + # carLive is the one that reads the islands; carAgents composes it with + # the remembered order. Both are checked, because the property is that + # NEITHER goes to the network for the agent list. + live_at = js.index("function carLive(") + live = js[live_at:js.index("function carArrange(", live_at)] + assert "agentsEl" in live, live + both = js[live_at:js.index("function paintCarousel(", live_at)] + # "fetch(" and not "fetch": the comment above it explains why + # RE-FETCHING would be wrong, and matching the bare word caught that. + assert "fetch(" not in both, both + + +class TestTheRadioSwitches: + """Wi-Fi and Bluetooth in the pull-down shade. + + Both go through the root drop box the power menu uses, because the obstacle + is the same one twice: the controller runs as `taos`, logind answers + "challenge" to CanPowerOff, NetworkManager answers `no` to + enable-disable-wifi, and /dev/rfkill is not writable by that user either. + """ + + @pytest.mark.parametrize("radio,on,verb", [ + ("wifi", True, "wifi-on"), + ("wifi", False, "wifi-off"), + ("bluetooth", True, "bt-on"), + ("bluetooth", False, "bt-off"), + ]) + def test_each_switch_writes_its_own_verb(self, monkeypatch, tmp_path, radio, on, verb): + """What lands in that file IS the contract with the root helper, so the + mapping is asserted rather than trusted to a dict literal.""" + target = tmp_path / "request" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(target)) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + monkeypatch.setattr(auth, "_read_radios", lambda: {"wifi": True}) + monkeypatch.setattr(auth.asyncio, "sleep", _no_sleep) + resp = _call(auth.set_lock_radios(_Req({"radio": radio, "on": on}))) + assert resp.status_code == 200 + assert target.read_text() == verb + + def test_an_unknown_radio_is_refused(self, monkeypatch): + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + for body in ({"radio": "microwave", "on": True}, + {"radio": "wifi"}, + {"radio": "wifi", "on": "yes"}, + {}): + assert _call(auth.set_lock_radios(_Req(body))).status_code == 400 + + def test_it_is_console_only_and_exempt(self, monkeypatch): + assert "/auth/lock-radios" in EXEMPT_PATHS + monkeypatch.setattr(auth, "_request_is_console", lambda _r: False) + assert _call(auth.lock_radios(_Req())).status_code == 403 + assert _call(auth.set_lock_radios( + _Req({"radio": "wifi", "on": True}))).status_code == 403 + + def test_the_answer_is_the_read_back_not_the_request(self, monkeypatch, tmp_path): + """A switch that reports what it ASKED for lies the moment the radio + refuses. This asks for wifi ON while the reader insists it is OFF, and + requires the refusal to win.""" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(tmp_path / "request")) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + monkeypatch.setattr(auth, "_read_radios", lambda: {"wifi": False}) + monkeypatch.setattr(auth.asyncio, "sleep", _no_sleep) + got = _body(_call(auth.set_lock_radios(_Req({"radio": "wifi", "on": True})))) + assert got["wifi"] is False, got + + def test_a_hard_blocked_radio_reads_as_off(self, monkeypatch): + """A physical kill switch is not something software can clear, so a + switch that ignored a hard block would show on and do nothing.""" + class Done: + returncode = 0 + stdout = "bluetooth unblocked blocked" + monkeypatch.setattr(auth.subprocess if hasattr(auth, "subprocess") else auth, + "run", lambda *a, **k: Done(), raising=False) + import subprocess as real + monkeypatch.setattr(real, "run", lambda *a, **k: Done()) + assert auth._read_radios().get("bluetooth") is False + + def test_an_unreadable_radio_is_absent_not_false(self, monkeypatch): + """Absent and off are different answers: the page disables the button + rather than showing a state nobody measured.""" + import subprocess as real + + def boom(*_a, **_k): + raise OSError("no such tool") + + monkeypatch.setattr(real, "run", boom) + assert auth._read_radios() == {} + + def test_the_page_disables_a_switch_it_could_not_read(self): + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function paintRadios(") + body = js[start:js.index("function setRadio(", start)] + assert "btn.disabled = true" in body, body[-400:] + + +class TestTheRadialChooserBlursTheScreen: + """Jay: "blur the screen when the rotary agent chooser is activated". + + It earns its place rather than being decoration: the faces are small, + low-contrast circles over a feed of cards and text, and the focused one is + hard to pick out without separation -- which is the one thing a chooser + driven by a PHYSICAL KEY has to get right, because your eye is not already + on the screen when it opens. + """ + + def test_opening_the_carousel_sets_the_blur_and_the_dim(self): + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function carShow(") + body = js[start:js.index("function startTalking(", start)] + # The value is now chosen between "1" (blur) and "dark" (hide), so the + # assertion is on the attribute being set, with the variants checked + # separately below. + assert 'setAttribute("data-radial"' in body, body + assert '"dark" : "1"' in body, body + assert 'scrim.hidden = false' in body, body + + def test_hiding_clears_the_blur(self): + """Left set, the whole screen stays blurred after the arc goes away -- + a phone that looks broken until something else happens to clear it.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function hideAll(") + body = js[start:js.index("function restartIdleHide(", start)] + assert 'removeAttribute("data-radial")' in body, body + + def test_hiding_leaves_the_scrim_alone_while_a_sheet_is_open(self): + """The scrim is shared. A sheet keeps it up through its own rule, so + hiding the element here would pull the dim out from under an open menu + the moment the volume bezel timed out behind it.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function hideAll(") + body = js[start:js.index("function restartIdleHide(", start)] + assert 'data-sheet' in body, body + assert 'scrim.hidden = true' in body, body + + def test_the_stylesheet_honours_the_blur_attribute(self): + """Both halves, or the attribute is set and nothing happens.""" + css = auth._LOCK_SCREEN_STYLE + assert 'data-radial="1"' in css + rule = css[css.index('.lockscreen[data-radial="1"]'):][:200] + assert "blur(" in rule, rule + + def test_summoned_onto_a_dark_panel_the_lock_screen_is_HIDDEN_not_blurred(self): + """Jay: "maybe we should enable the rotary menu when screen is off. It + will look nice against the black oled screen." + + On OLED an unlit pixel emits nothing, so hiding the lock screen puts the + faces on real black -- which is the effect, and the one thing an OLED + does that no amount of blur imitates. A blurred lock screen would still + be a lit photograph of a lock screen. + """ + css = auth._LOCK_SCREEN_STYLE + assert 'data-radial="dark"' in css + rule = css[css.index('.lockscreen[data-radial="dark"]'):][:260] + assert "visibility: hidden" in rule, rule + assert "blur(" not in rule, rule + # And the scrim goes to true black behind it. + assert 'data-radial="dark"] ~ .ls-scrim' in css + + def test_the_dark_variant_is_cleared_when_the_arc_closes(self): + """Left set, the next ordinary open would hide the lock screen instead + of blurring it -- and the phone would look like it had gone blank.""" + js = auth._LOCK_SCREEN_SCRIPT + hide = js[js.index("function hideAll("):js.index("function restartIdleHide(")] + assert "carDark = false" in hide, hide + + def test_the_volume_bezel_does_not_blur_the_screen(self): + """Deliberately not: the bezel is a transient heads-up for a key you + are already holding, and blurring the whole screen to show a volume + level would be heavy-handed.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function volShow(") + body = js[start:js.index("function hideAll(", start)] + assert "data-radial" not in body, body + + +class TestHoldingToTalkDoesNotAlsoCycle: + """Jay, from the glass: "holding to talk doesnt work, it moves to the next + agent and then starts input capture". + + The press branch advanced the selection immediately and the hold timer then + fired on top of it, so one hold did both. A press cannot be classified until + it ENDS, so the only thing a press may do while the arc is open is start the + clock; the cycle happens on release, and only if the press was a tap. + """ + + @staticmethod + def _volume_key_source(): + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function volumeKey(") + return js[start:js.index("// ------", start)] + + def test_the_press_branch_does_not_cycle_the_selection(self): + """The bug, asserted where it lived. carIndex must not move on a press + while the arc is open.""" + src = self._volume_key_source() + press = src[src.index('if (action === "press")'):src.index("// RELEASE")] + assert "carIndex +=" not in press, press + + def test_the_release_branch_is_what_cycles(self): + src = self._volume_key_source() + release = src[src.index("// RELEASE"):] + assert "carIndex +=" in release, release + assert "paintCarousel()" in release, release + + def test_a_release_after_talking_stops_and_does_not_cycle(self): + """The discriminating case: the same release must end a transmission + OR move one agent, never both.""" + src = self._volume_key_source() + release = src[src.index("// RELEASE"):] + talk = release[release.index("if (talking)"):] + # stopTalking comes first and returns before the cycle is reached. + assert talk.index("stopTalking()") < talk.index("carIndex +="), talk + assert "return" in talk[:talk.index("carIndex +=")], talk + + def test_a_hold_that_failed_to_start_talking_is_still_not_a_tap(self): + """If the arc closed under the hold, `talking` is false -- but it was + still a hold, and reading it as a tap would advance the selection on + release. pressWasHold carries that.""" + js = auth._LOCK_SCREEN_SCRIPT + assert "pressWasHold" in js + src = self._volume_key_source() + release = src[src.index("// RELEASE"):] + assert release.index("pressWasHold") < release.index("carIndex +="), release + + def test_the_hold_timer_marks_the_press_before_talking(self): + """One place sets the flag and starts the transmission, so the two + cannot drift apart.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function armTalk(") + body = js[start:js.index("function startTalking(", start)] + assert "pressWasHold = true" in body and "startTalking()" in body, body + + def test_the_bezel_still_nudges_on_press(self): + """Hold has no second meaning over the bezel, and a volume key that + waited for the release would feel laggy where people expect it to be + immediate.""" + src = self._volume_key_source() + press = src[src.index('if (action === "press")'):src.index("// RELEASE")] + assert "nudgeVolume(" in press, press + + +class TestTheArcRemembersWhereItWasLeft: + """Jay: "we need the rotary chooser to remember its position, so a person + can leave their most used agent ready in walking talkie mode. Might be best + to have them auto arrange in order of last used too." + + Two separate pieces of state, because they answer different questions: + which agent the arc OPENS on, and what ORDER the faces are in. They agree + when the agent you parked on is the one you last used, and diverge when you + park on one without talking to it. + """ + + def test_the_remembered_focus_is_a_NAME_not_an_index(self): + """Agents come and go and the reordering moves them, so a remembered + index would quietly point at a different face -- the kind of bug that + looks like the feature working until it picks the wrong agent.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function carShow(") + body = js[start:js.index("function startTalking(", start)] + assert "carFocusName" in body, body + assert ".name === carFocusName" in body, body + + def test_opening_does_not_reset_the_position(self): + """The branch that opens the arc from rest must NOT zero carIndex, or + every open lands on the front however it was left.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function volumeKey(") + body = js[start:js.index("// ------", start)] + rest = body[body.index("if (!carOpen && !volOpen)"):] + reveal = rest[:rest.index("restartIdleHide();")] + assert "carIndex = 0" not in reveal, reveal + + def test_a_departed_agent_falls_back_to_the_front(self): + """If the remembered agent is gone, the arc must land somewhere real + rather than on an index that no longer exists.""" + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function carShow(") + body = js[start:js.index("function startTalking(", start)] + assert "carIndex = 0" in body, body + assert body.index("carIndex = 0") < body.index("carFocusName"), body + + def test_nothing_clobbers_the_restored_position_before_it_is_painted(self): + """The property Jay actually asked for, and the one my first pass + missed. + + A mutation that let the restore run and then wrote `carIndex = 0` + AFTER it left every other test in this class green: they assert the + restore MECHANISM exists, not that its result survives to the paint. + Same shape as the repair path that 63 assertions missed -- presence is + not effect. So this reads the span between the restore and the paint + and requires nothing to touch carIndex in it. + """ + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function carShow(") + body = js[start:js.index("function startTalking(", start)] + restore_end = body.index("=== carFocusName") + paint_at = body.index("paintCarousel()", restore_end) + # Past the end of the restore loop's own statement, up to the paint. + after_loop = body[body.index("}", body.index("}", restore_end) + 1):paint_at] + assert "carIndex" not in after_loop, ( + "something writes carIndex between the restore and the paint, so " + "the remembered position is computed and thrown away:\n" + after_loop + ) + + def test_the_order_is_frozen_while_the_arc_is_open(self): + """Re-sorting on every repaint would shuffle the faces under the thumb + between one key press and the next.""" + js = auth._LOCK_SCREEN_SCRIPT + show = js[js.index("function carShow("):js.index("function startTalking(")] + assert "carOrder = carArrange()" in show, show + hide = js[js.index("function hideAll("):js.index("function restartIdleHide(")] + assert "carOrder = null" in hide, hide + + def test_last_used_is_recorded_on_TALKING_not_on_focus(self): + """Cycling past six agents to reach one would otherwise rewrite the + whole order on the way there.""" + js = auth._LOCK_SCREEN_SCRIPT + talk = js[js.index("function startTalking("):js.index("function stopTalking(")] + assert "carUsed[" in talk, talk + remember = js[js.index("function rememberFocus("):js.index("function startTalking(")] + assert "carUsed[" not in remember, remember + + def test_ties_keep_the_islands_own_order(self): + """Agents never talked to should stay in the arrangement the user + already sees behind the arc, not an arbitrary one.""" + js = auth._LOCK_SCREEN_SCRIPT + arrange = js[js.index("function carArrange("):js.index("function carAgents(")] + assert "a.index - b.index" in arrange, arrange + + def test_storage_failures_do_not_break_a_keypress(self): + """localStorage throws in a private context and can come back empty. + Nothing here is worth failing a volume key over.""" + js = auth._LOCK_SCREEN_SCRIPT + save = js[js.index("function carSave("):js.index("function carLive(")] + assert "try {" in save and "catch" in save, save + # And the restore on load is guarded too. + assert "JSON.parse(window.localStorage.getItem(CAR_STORE)" in js + + +class TestTheOpeningPressOnlyOpens: + """Jay: "the first click of the volume down should not rotate the menu just + make it appear." + + The arc opens on the press, and by the time that press is RELEASED the arc + is open -- so the release handler saw an open arc and cycled it. The menu + appeared already one agent along. Same shape as the hold bug: a press that + did something on the way down must not also act on the way up. + """ + + @staticmethod + def _src(): + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function volumeKey(") + return js[start:js.index("// ------", start)] + + def test_the_opening_press_is_marked(self): + src = self._src() + rest = src[src.index("if (!carOpen && !volOpen)"):] + assert "pressOpened = true" in rest[:rest.index("restartIdleHide();")], rest + + def test_the_release_of_an_opening_press_does_not_cycle(self): + """The discriminating order: the pressOpened guard has to return BEFORE + the cycle is reached, or marking it changes nothing.""" + src = self._src() + release = src[src.index("// RELEASE"):] + guard = release.index("pressOpened") + cycle = release.index("carIndex +=") + assert guard < cycle, release + assert "return" in release[guard:cycle], release[guard:cycle] + + def test_the_flag_is_cleared_on_every_press(self): + """Left set, the NEXT tap would be swallowed too -- a menu that needs + two presses per step.""" + src = self._src() + press = src[src.index('if (action === "press")'):src.index("// RELEASE")] + assert "pressOpened = false" in press, press + + +class TestTheArcEmergesFromTheBlack: + """Jay: "when i activate the rotary menu with the screen off the lock screen + flashes into view first, it breaks the visual appeal" and "it would be nice + if the the rotary menu could have an appear effect like fading into view out + of the deep black oled display." + + The flash was an ORDERING fault in the compositor script, not CSS: it woke + the panel before telling the page, so a genuinely lit frame of full lock + screen was shown before the page could hide it. + """ + + def test_the_dark_arc_scales_from_the_pivot_not_the_centre(self): + """The pivot is the whole conceit of this layout, so the animation + should unfurl from under the thumb rather than swell out of the middle + of a dark screen.""" + css = auth._LOCK_SCREEN_STYLE + rule = css[css.index('data-radial="dark"] ~ #ls-carousel {'):][:400] + assert "transform-origin: 0 var(--ls-car-pivot)" in rule, rule + assert "scale(" in rule, rule + + def test_the_dark_fade_is_slower_than_the_lit_one(self): + """Over a blurred lock screen the arc only has to arrive; over true + black it is the only thing on the panel, so a 200ms snap reads as a + flash. Compared as NUMBERS rather than trusting the comment.""" + import re + + css = auth._LOCK_SCREEN_STYLE + lit = css[css.index(".ls-carousel {"):] + lit = lit[:lit.index("}")] + lit_ms = max(int(m) for m in re.findall(r"(\d+)ms", lit)) + dark = css[css.index('data-radial="dark"] ~ #ls-carousel {'):][:400] + dark_ms = max(int(m) for m in re.findall(r"(\d+)ms", dark)) + assert dark_ms > lit_ms, (dark_ms, lit_ms) + + def test_the_banner_arrives_after_the_faces(self): + """Text arriving first on a black screen is what makes an animation + feel like a page load rather than a thing appearing.""" + import re + + css = auth._LOCK_SCREEN_STYLE + faces = css[css.index('data-radial="dark"] ~ #ls-carousel .ls-face'):][:300] + banner = css[css.index('data-radial="dark"] ~ #ls-carousel .ls-carousel-banner'):][:300] + face_delay = max(int(m) for m in re.findall(r"ease (\d+)ms", faces) or ["0"]) + banner_delay = max(int(m) for m in re.findall(r"ease (\d+)ms", banner) or ["0"]) + assert banner_delay > face_delay, (banner_delay, face_delay) + + def test_reduced_motion_drops_the_emergence(self): + css = auth._LOCK_SCREEN_STYLE + assert css.count("prefers-reduced-motion") >= 1 + tail = css[css.index('data-radial="dark"] ~ #ls-carousel'):] + assert "prefers-reduced-motion" in tail, "the dark arc ignores reduced motion" + + +class TestClosingTheArcLeavesTheRightThingOnScreen: + """Jay: "after using the rotary menu instead of the screen going off it + shows the lock screen background grey." + + Two faults in one symptom. data-blanked was never cleared -- the screen-on + handler skips it while the arc is up, and nothing else did it -- so the + lock screen stayed at opacity 0 and the page body showed through. And even + cleared, revealing the lock screen is the wrong answer: the screen was off + before the arc, so it should be off after. + """ + + @staticmethod + def _hide_all(): + js = auth._LOCK_SCREEN_SCRIPT + start = js.index("function hideAll(") + return js[start:js.index("function restartIdleHide(", start)] + + def test_a_dark_summon_goes_back_to_black(self): + body = self._hide_all() + assert "wasDark" in body, body + assert 'setAttribute("data-blanked", "1")' in body, body + + def test_an_ordinary_close_restores_the_lock_screen(self): + """The other arm. Always re-blackening would leave a phone that was + awake staring at a black screen.""" + body = self._hide_all() + assert 'removeAttribute("data-blanked")' in body, body + + def test_hideAll_reads_its_state_before_destroying_any_of_it(self): + """The bug that made two separate fixes inert. + + hideAll used to removeAttribute("data-on") at the top and then ask, + further down, whether data-on was set -- so the branch recording the + parked agent could never run. Jay: "the last used agent isnt always the + first one in the list." The code was there, in the right order by text, + and could not fire. Presence is not effect. + + So both reads now happen before any removal, and this asserts that + ordering directly: every read of state comes before the first + removeAttribute in the function. + """ + body = self._hide_all() + first_teardown = body.index("removeAttribute") + for read in ('var wasOpen =', 'var wasDark ='): + assert body.index(read) < first_teardown, ( + read + " happens after state is already destroyed:\n" + body + ) + # And the recording itself, which depends on wasOpen. + assert body.index("carUsed[") < first_teardown, body + + def test_darkness_is_read_from_the_element_not_a_variable(self): + """On the element, because two separate handlers need the answer and an + attribute cannot drift from what is on screen.""" + body = self._hide_all() + assert 'hasAttribute("data-fromdark")' in body, body + + def test_both_volume_surfaces_record_the_dark_summon(self): + """data-radial is set by the ARC only, so reading it left a + volume-bezel session on the lock screen. Jay: "the same after changing + the volume with the screen off ... im left at the lock screen instead + of screen off." The flag is set in the shared from-rest branch, before + either surface is chosen.""" + js = auth._LOCK_SCREEN_SCRIPT + src = js[js.index("function volumeKey("):] + src = src[:src.index("// ------")] + rest = src[src.index("if (!carOpen && !volOpen)"):] + setter = rest[:rest.index('if (key === "up")')] + assert 'setAttribute("data-fromdark", "1")' in setter, setter + + def test_the_wake_does_not_reveal_the_lock_screen_under_either_surface(self): + """The screen-on handler asked about the arc alone, which is why the + lock screen appeared behind the slider the moment the panel woke.""" + js = auth._LOCK_SCREEN_SCRIPT + at = js.index('addEventListener("screen-on"') + handler = js[at:js.index("});", at)] + assert 'hasAttribute("data-fromdark")' in handler, handler + assert handler.index("data-fromdark") < handler.index("removeAttribute"), handler + + def test_the_flag_is_cleared_when_the_surface_closes(self): + """Left set, the next ordinary wake would stay black -- a phone that + looks dead.""" + body = self._hide_all() + assert 'removeAttribute("data-fromdark")' in body, body + + def test_the_page_does_not_power_the_panel_itself(self): + """Keeping the page black is how the screen is returned to dark. The + page has no business being able to power the output down, and swayidle + blanks it properly a moment later.""" + body = self._hide_all() + for forbidden in ("power off", "lock-screen-off", "taos-kiosk-screen"): + assert forbidden not in body, forbidden + + +class TestNothingGreyShowsThrough: + """Jay, twice: "it shows the lock screen background grey" and "it even + flashes sometimes on rotary start/open". + + `body` carries a dark GREY GRADIENT for the ordinary sign-in card, and + .lockscreen has no background of its own -- so hiding the lock screen + revealed that gradient. Hiding a transparent layer over grey shows grey. + Both of my earlier fixes moved the transparent layer around and never + touched what was underneath it. + """ + + def test_the_body_has_a_black_state(self): + # The RULE, not the name: the comment above it spells out + # "body.ls-black (0,1,1) beats body (0,0,1)" and matching the bare + # selector found the prose. Fifth time tonight. + css = auth._LOCK_SCREEN_STYLE + assert "body.ls-black {" in css + rule = css[css.index("body.ls-black {"):][:80] + assert "#000" in rule, rule + + def test_the_grey_gradient_is_what_it_overrides(self): + """Named here so the next person knows WHY a black body rule exists, + and so this test fails loudly if the gradient is ever removed and the + override becomes cargo.""" + # The gradient is in _AUTH_BASE_STYLE, not the lock screen's own sheet + # -- which is part of why it went unnoticed: the grey was being set by a + # stylesheet the lock screen work never touched. + base = auth._AUTH_BASE_STYLE + body = base[base.index("body {"):] + body = body[:body.index("}")] + assert "linear-gradient" in body, body + assert "#141415" in body or "#202024" in body, body + # And the override must be able to beat it: higher specificity, and it + # is served after the base sheet on the page. + assert "body.ls-black {" in auth._LOCK_SCREEN_STYLE + + def test_every_hide_of_the_lock_screen_also_blackens_the_body(self): + """One helper owns it. Two flags for one visual state is how a grey + frame gets in, which is exactly what happened.""" + js = auth._LOCK_SCREEN_SCRIPT + assert "function setBlack(" in js + # Each site that sets or clears data-blanked must pair with setBlack. + for marker in ('screenEl.setAttribute("data-blanked", "1");', + 'screenEl.removeAttribute("data-blanked");'): + at = 0 + while True: + at = js.find(marker, at) + if at == -1: + break + window = js[at:at + 260] + assert "setBlack(" in window, ( + "a data-blanked change with no matching setBlack:\n" + window + ) + at += len(marker) + + def test_the_dark_arc_blackens_the_body_too(self): + """The open flash: the lock screen went hidden while the black scrim + had not painted, so the gradient showed for a frame.""" + js = auth._LOCK_SCREEN_SCRIPT + show = js[js.index("function carShow("):js.index("function startTalking(")] + assert "setBlack(true)" in show, show + + +class TestParkingOnAnAgentCountsAsUsingIt: + """Jay: "the last used agent isnt always the first one in the list." + + `used` was only written when a transmission STARTED, so parking on an agent + without holding to talk left the order untouched -- and that agent did not + come first next time, which is precisely what he was seeing. + """ + + def test_closing_records_the_parked_agent(self): + js = auth._LOCK_SCREEN_SCRIPT + hide = js[js.index("function hideAll("):js.index("function restartIdleHide(")] + assert "carUsed[" in hide, hide + assert "carSave()" in hide, hide + + def test_it_is_recorded_once_on_close_not_on_every_step(self): + """Recording each step would rewrite the whole order while cycling past + six agents to reach one.""" + js = auth._LOCK_SCREEN_SCRIPT + src = js[js.index("function volumeKey("):js.index("// ------", js.index("function volumeKey("))] + release = src[src.index("// RELEASE"):] + assert "carUsed[" not in release, release + + def test_it_only_records_while_the_arc_was_actually_open(self): + """hideAll also runs for the volume bezel, which has no focused agent + and must not write an ordering entry.""" + js = auth._LOCK_SCREEN_SCRIPT + hide = js[js.index("function hideAll("):js.index("function restartIdleHide(")] + guard = hide.index('carEl.getAttribute("data-on") === "1"') + assert guard < hide.index("carUsed["), hide + + +class TestDownReachesTheSecondMostUsedAgent: + """Jay: "the ordering of recently used needs reversing so i can press down + to get to my second most used agent quickly using the volume down button." + + This is a consequence of two earlier decisions rather than a free choice: + the arc opens focused on the most recently used agent, and volume-down + decrements the index. With the most recent at the FRONT, down had nowhere + to go but round the back to the least used. With it at the END, down walks + most-used -> second -> third. + """ + + def test_the_sort_puts_the_most_recent_LAST(self): + js = auth._LOCK_SCREEN_SCRIPT + arrange = js[js.index("function carArrange("):js.index("function carAgents(")] + assert "a.used - b.used" in arrange, arrange + assert "b.used - a.used" not in arrange, arrange + + def test_down_still_decrements(self): + """The direction of travel is unchanged; only the arrangement moved. If + both were flipped the bug would be back with two wrongs cancelling into + the same wrong.""" + js = auth._LOCK_SCREEN_SCRIPT + src = js[js.index("function volumeKey("):] + src = src[:src.index("// ------")] + release = src[src.index("// RELEASE"):] + assert 'carIndex += (key === "up" ? 1 : -1)' in release, release + + def test_ties_still_keep_the_islands_own_order(self): + """Agents never talked to all share used=0, so without a stable tie + they would shuffle on every open.""" + js = auth._LOCK_SCREEN_SCRIPT + arrange = js[js.index("function carArrange("):js.index("function carAgents(")] + assert "a.index - b.index" in arrange, arrange + + +class TestBlankingTakesTheVolumeSurfacesWithIt: + """Jay: "if i change volume with screen off after using the rotary menu the + rotary menu flashes up first and vice versa." + + The symmetry is the tell -- whichever surface was used LAST is the one that + flashes. data-blanked hides .lockscreen, but the bezel and the arc are + SIBLINGS of it, so a panel that blanked while one was up left a last + painted frame of black WITH that surface still on it, and the next wake + showed it before the new one could paint. + """ + + @staticmethod + def _blank_handler(): + js = auth._LOCK_SCREEN_SCRIPT + at = js.index('screenEl.setAttribute("data-instant", "1");\n hideAll();') + return js[at - 1400:at + 400] + + def test_blanking_dismisses_them(self): + assert "hideAll();" in self._blank_handler() + + def test_it_dismisses_them_without_a_fade(self): + """A 200ms fade has nowhere to go on a panel powering down in 120ms, + and an unfinished fade is exactly the half-lit ghost.""" + h = self._blank_handler() + assert h.index('setAttribute("data-instant", "1")') < h.index("hideAll();"), h + + def test_the_instant_flag_reaches_the_volume_surfaces(self): + """It only covered the sheets, which is why hiding the lock screen + never reached the arc.""" + css = auth._LOCK_SCREEN_STYLE + at = css.index('data-instant="1"') + rule = css[at:css.index("}", at) + 1] + assert "#ls-carousel" in rule, rule + assert "#ls-vol" in rule, rule + + def test_blackness_is_set_after_the_dismissal(self): + """hideAll decides blackness for itself, so setting it first would be + undone by the very call that is supposed to tidy up.""" + h = self._blank_handler() + assert h.index("hideAll();") < h.index('setAttribute("data-blanked", "1")'), h + + +class TestOpenedFromStandbyReturnsToStandby: + """Jay: "sometimes after using the radial dial and it times out im still + being sent to the lock screen instead of screen off, can we not have a + rule, if opened from standby, back to standby." + + The rule was already the intent; what was wrong was the definition of + standby. It was being taken from the COMPOSITOR -- whether the output was + powered -- and those two facts come apart: after a dark session the page is + black while the panel is still on, because swayidle has not reached its + timeout. A second summon in that window read "panel is on", treated it as + an awake summon, and revealed the lock screen on close. Hence "sometimes". + """ + + @staticmethod + def _rest_branch(): + js = auth._LOCK_SCREEN_SCRIPT + src = js[js.index("function volumeKey("):] + src = src[:src.index("// ------")] + rest = src[src.index("if (!carOpen && !volOpen)"):] + return rest[:rest.index("restartIdleHide();")] + + def test_standby_is_decided_by_the_page_not_the_compositor(self): + branch = self._rest_branch() + assert 'hasAttribute("data-blanked")' in branch, branch + + def test_the_compositor_hint_is_still_honoured(self): + """It is the only signal available for the first summon after a + controller restart, when the page has never seen a screen-off.""" + branch = self._rest_branch() + assert "fromDark" in branch, branch + # Either source is enough. + assert "||" in branch[branch.index("var dark ="):branch.index("var dark =") + 160], branch + + def test_the_arc_styling_follows_the_same_decision(self): + """carDark drives whether the arc goes dark or merely blurs. Left on + fromDark alone it would blur over a lock screen nobody can see, and + then the close would reveal it.""" + branch = self._rest_branch() + assert "carDark = dark;" in branch, branch + assert "carDark = !!fromDark" not in branch, branch + + +#: The menu driven as a menu: build it, tap a row, answer its question. +#: +#: Source-text assertions can say the gate is WRITTEN. They cannot say it is +#: REACHED -- and the thing being asserted here is what a locked phone sends +#: when a stranger taps "Stop all agents", which is a property of the handlers, +#: not of the file. +_MENU_HARNESS = _DOM + _EVENTS + r""" +var powerBody = makeNode("div"); +powerBody.setAttribute("id", "ls-power-body"); +var unlockNote = makeNode("div"); +unlockNote.setAttribute("id", "ls-unlock-note"); +unlockNote.hidden = true; + +// replaceWith, which confirmPower uses to swap the tapped row for its own +// question. Not in the shared stand-in: absent, it is a TypeError that reads +// from here as the confirm step being broken rather than as a harness gap. +var __baseMake = makeNode; +makeNode = function (tag) { + var el = __baseMake(tag); + el.replaceWith = function (next) { + var p = this.parent; + if (!p) return; + p.children.splice(p.children.indexOf(this), 1, next); + next.parent = p; + this.parent = null; + }; + return el; +}; +document.createElement = makeNode; + +// RECORDED, not stubbed away. A fetch that silently did nothing would satisfy +// "no request was sent" for the wrong reason, and what reaches the server from +// a locked screen is this file's entire subject. +var POSTED = []; +function fetch(url, opts) { + POSTED.push({ url: url, body: (opts && opts.body) || null }); + var chain = { then: function () { return chain; }, + catch: function () { return chain; } }; + return chain; +} + +// The real one opens the passcode sheet. Here it is the signal being measured. +var PASSCODE = 0; +function openPasscode() { PASSCODE += 1; } + +__SOURCE__ + +function deepText(el, label) { + if (el.textContent === label) return true; + for (var i = 0; i < el.children.length; i++) { + if (deepText(el.children[i], label)) return true; + } + return false; +} +function rowFor(label) { + for (var i = 0; i < powerBody.children.length; i++) { + if (deepText(powerBody.children[i], label)) return powerBody.children[i]; + } + throw new Error("no menu row labelled " + label); +} +function confirmBox() { + for (var i = 0; i < powerBody.children.length; i++) { + var c = powerBody.children[i]; + if (String(c.className).indexOf("ls-power-confirm") !== -1) return c; + } + return null; +} + +var SCN = JSON.parse(process.env.LS_MENU); +paintPowerMenu(); +fire(rowFor(SCN.label), "click"); +var box = confirmBox(); +var out = { confirmed: !!box }; +if (box && SCN.go) { + var row = box.querySelector(".ls-power-confirm-row"), go = null; + for (var i = 0; row && i < row.children.length; i++) { + if (row.children[i].getAttribute("data-go") === "1") go = row.children[i]; + } + if (!go) throw new Error("the confirm offers no way to go ahead"); + fire(go, "click"); +} +out.posted = POSTED; +out.passcode = PASSCODE; +out.pending = window.__lsPendingPowerAction || null; +out.note = { text: unlockNote.textContent, hidden: !!unlockNote.hidden }; +out.rows = powerBody.children.length; +process.stdout.write(JSON.stringify(out)); +""" + + +def _menu_source() -> str: + """The SHIPPED menu, lifted out of the served script rather than re-typed.""" + return "\n".join([ + _var("POWER_ITEMS"), + _function("powerResult"), + _function("runPowerAction"), + _function("requirePasscodeForPower"), + _function("paintPowerMenu"), + _function("confirmPower"), + ]) + + +def _tap(label, *, go=True): + node = shutil.which("node") or shutil.which("nodejs") + if not node: + pytest.skip("node is not installed") + script = _MENU_HARNESS.replace("__SOURCE__", _menu_source()) + env = dict(os.environ) + env["LS_MENU"] = json.dumps({"label": label, "go": go}) + 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) + + +class TestStoppingEveryAgentDemandsThePasscode: + """Jay's ruling, after @taOS-dev put the question to him in review. + + The rule was already written on this screen twice -- the agent menu + "collects the INTENT and then asks for the passcode", the decision sheet + says "Unlock to approve this" -- and the power menu was the one place it + was not applied. Stopping a SINGLE agent demanded an unlock; stopping all + of them did not. + + Power off and Restart are deliberately untouched: holding the hardware key + already took the phone down from this screen, so the menu adds nothing + there. The key cannot drain every agent on the device. That asymmetry is + the whole reason this is the one verb that moved, which is why + `test_power_off_is_not_gated_with_it` is here -- without it, gating the + entire menu would pass everything else in this class. + """ + + def test_the_confirm_alone_sends_nothing(self): + """Tapping the row asks the question and stops there.""" + out = _tap("Stop all agents", go=False) + assert out["confirmed"] is True + assert out["posted"] == [] + assert out["passcode"] == 0 + + def test_answering_yes_raises_the_passcode_instead_of_stopping_them(self): + out = _tap("Stop all agents") + assert out["posted"] == [], ( + "a locked screen sent the stop to the server: %r" % (out["posted"],) + ) + assert out["passcode"] == 1 + + def test_the_intent_is_kept_for_the_signed_in_user(self): + """Taken, not thrown away: the drain happens once a session exists.""" + out = _tap("Stop all agents") + assert out["pending"] and out["pending"]["action"] == "stop-agents" + + def test_the_keypad_says_what_it_is_for(self): + """An unexplained keypad straight after a menu tap reads as the phone + having simply re-locked itself. Same note the agent menu writes.""" + out = _tap("Stop all agents") + assert out["note"]["hidden"] is False + assert out["note"]["text"] == "Unlock to stop all agents" + + def test_the_menu_is_whole_again_behind_the_keypad(self): + """The confirm REPLACED the row. Left removed, the next hold of the + power key shows a menu one item short.""" + out = _tap("Stop all agents") + start = auth._LOCK_SCREEN_SCRIPT.index("var POWER_ITEMS") + table = auth._LOCK_SCREEN_SCRIPT[start:auth._LOCK_SCREEN_SCRIPT.index("];", start)] + assert out["rows"] == table.count('["') + + def test_power_off_is_not_gated_with_it(self): + """The discriminating case, and the positive control for the recorder: + a verb that IS meant to go through pre-auth still does, and the harness + can see a POST when one happens.""" + out = _tap("Power off") + assert out["passcode"] == 0 + assert len(out["posted"]) == 1 + assert out["posted"][0]["url"] == "/auth/lock-power-action" + assert json.loads(out["posted"][0]["body"])["action"] == "poweroff" + + def test_the_verb_is_gated_not_removed(self): + """@taOS-dev's warning: the server must still answer `stop-agents` once + a session exists. What changed is who can ask, not what exists.""" + assert "stop-agents" in auth._POWER_ACTIONS + + +class TestTheLockScreenCameraShortcut: + """Jay: "wire it up to the lock screen button". + + The button existed before the app did and said "No camera app yet", which + was the honest answer at the time. There is a camera app now -- taos-camerad + serves it and taos-app-launch opens it in its own window -- so the button + opens it. + + The pre-auth question is the same one the power menu had to answer, and it + is answered in the route's docstring: a camera reachable from a locked phone + is what every phone does, and this one shows a viewfinder and the photos + taken from it, not a signed-in user's files. + """ + + def test_the_page_no_longer_claims_there_is_no_camera_app(self): + js = auth._LOCK_SCREEN_SCRIPT + assert "No camera app yet" not in js + assert '"/auth/lock-app"' in js + + def test_the_app_list_is_a_closed_map_the_page_cannot_name_into(self): + """The value reaches ROOT through the drop box, so the page must choose + from a list rather than supply a command.""" + assert auth._LOCK_APPS == {"camera": "app-camera"} + + def test_opening_the_camera_writes_the_drop_box_verb(self, monkeypatch, tmp_path): + req = tmp_path / "request" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(req)) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + resp = _call(auth.lock_app(_Req({"app": "camera"}))) + assert resp.status_code == 200 + assert _body(resp)["ok"] is True + # The VERB, not the app name: what reaches root is what this asserts. + assert req.read_text() == "app-camera" + + def test_an_app_that_is_not_listed_is_refused_and_writes_nothing( + self, monkeypatch, tmp_path + ): + """The discriminating case. Without it, a handler that wrote whatever it + was given would satisfy the test above.""" + req = tmp_path / "request" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(req)) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: True) + for name in ("poweroff", "app-camera", "../../etc/passwd", "", "Camera"): + resp = _call(auth.lock_app(_Req({"app": name}))) + assert resp.status_code == 400, name + assert not req.exists(), name + + def test_it_is_console_only(self, monkeypatch, tmp_path): + req = tmp_path / "request" + monkeypatch.setattr(auth, "_POWER_REQUEST", str(req)) + monkeypatch.setattr(auth, "_request_is_console", lambda _r: False) + resp = _call(auth.lock_app(_Req({"app": "camera"}))) + assert resp.status_code == 403 + assert not req.exists() + + def test_it_is_reachable_before_sign_in(self): + """It is pressed FROM the lock screen, so a route that 401s is a button + that does nothing -- the failure the stats panel already had once.""" + assert "/auth/lock-app" in EXEMPT_PATHS + + def test_the_power_verbs_did_not_grow(self): + """@taOS-dev asked for _POWER_ACTIONS to stay a closed set of five. + Opening an app shares the channel, not the vocabulary.""" + assert len(auth._POWER_ACTIONS) == 5 + assert "app-camera" not in auth._POWER_ACTIONS diff --git a/tests/test_lock_screen_repaint.py b/tests/test_lock_screen_repaint.py index 20594d371..d3f96e8ce 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; }, @@ -133,6 +136,14 @@ def _var(name: str) -> str: this._text = String(v); } }); + // parentNode, the name the SHIPPED code uses. The stand-in stored the link + // as `parent` only, so `el.parentNode` was undefined in here: paintDecisions' + // "step out of the alerts panel" never ran, and the re-attach guard + // (`el.parentNode !== notifsEl`) was true unconditionally. Both read as + // working. An alias rather than a second field, or the two names drift. + Object.defineProperty(el, "parentNode", { + get: function () { return this.parent; } + }); Object.defineProperty(el, "firstChild", { get: function () { return this.children.length ? this.children[0] : null; } }); @@ -152,9 +163,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/tests/test_lock_screen_views.py b/tests/test_lock_screen_views.py index ec7740cab..810e6f633 100644 --- a/tests/test_lock_screen_views.py +++ b/tests/test_lock_screen_views.py @@ -27,7 +27,7 @@ from tinyagentos.routes import auth from tinyagentos.routes.auth import _LOCK_SCREEN_SCRIPT as LOCK_SCRIPT -from test_lock_screen_gestures import _balanced +from test_lock_screen_gestures import _balanced, _function class Failed(AssertionError): @@ -90,6 +90,12 @@ def _show_view() -> str: removeEventListener: function () {}, setAttribute: function (k, v) { this._attrs[k] = String(v); }, removeAttribute: function (k) { delete this._attrs[k]; }, + // The shipped code asks this before toggling the feed. Without it the + // stand-in throws, which reads as the feature being broken rather than as + // the harness being short of a method. + hasAttribute: function (k) { + return Object.prototype.hasOwnProperty.call(this._attrs, k); + }, getAttribute: function (k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; }, @@ -127,7 +133,10 @@ def _show_view() -> str: def _run_views(*, target: str, mutate: bool = False) -> dict: """Drive the real showView and report what it did to the panels.""" - source = _show_view() + # showView now clears the hidden state as well, so the real setFeedHidden + # comes along rather than a stub -- a stub here would let showView claim to + # restore a feed it never touched. + source = _show_view() + "\n" + _function("setFeedHidden") if mutate: # THE MUTATION: drive `hidden` instead of `data-off`. This is the shape # the code would have had if the two owners had not been separated, and @@ -613,3 +622,158 @@ def test_the_card_cap_still_fits_the_panel(self): viewport_css_px = 1080 / self.OUTPUT_SCALE side_padding = 10 assert self._token("--ls-card-w") <= viewport_css_px - 2 * side_padding + + +def _tab_click_handler() -> str: + """The SHIPPED click handler, lifted out of the served script. + + Not re-typed as `if (key === currentView) toggle()`: that would be a test of + the copy, and the whole question here is what the real handler does when the + tab you press is the one already selected. + """ + head = 'viewsEl.addEventListener("click", function (ev) {' + assert head in LOCK_SCRIPT, "the view row's click handler has moved" + start = LOCK_SCRIPT.index(head) + head.index("function (ev) {") + body = _balanced(LOCK_SCRIPT, start, "{", "}") + # _balanced returns from `start`, so `body` already IS the function expression. + return "var onTabClick = " + body + ";" + + +def _run_toggle(presses, *, mutate: bool = False) -> list: + """Press a sequence of tabs and report whether the feed is showing.""" + source = _show_view() + "\n" + _function("setFeedHidden") + "\n" \ + + _function("feedIsHidden") + "\n" + _tab_click_handler() + if mutate: + # THE MUTATION: the active tab switches to itself instead of toggling, + # which is what the code did before Jay asked for this. Rendered state + # is identical on every OTHER press, so only the repeat press can tell + # them apart. + source = source.replace("setFeedHidden(!feedIsHidden());", "showView(key, false);") + + program = ( + _DOM + + "\nvar VIEWS = %s;\nvar VIEW_DEFAULT = %s;\n" + % (json.dumps({k: 1 for k in _VIEW_KEYS}), json.dumps(_VIEW_KEYS[0])) + + r""" +var panels = %(keys)s.map(function (k) { + return makeEl({ sel: [], attrs: { "data-view": k } }); +}); +var tabs = %(keys)s.map(function (k) { + return makeEl({ sel: [".ls-view-tab"], attrs: { "data-view": k } }); +}); +var feedEl = makeEl({ kids: panels }); +var viewsEl = makeEl({ kids: tabs }); +var currentView = VIEW_DEFAULT; +function viewTabs() { return tabs; } +function renderView() {} +function syncFeedFade() {} +function startStats() {} +function stopStats() {} + +%(source)s + +function tabFor(key) { + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].getAttribute("data-view") === key) return tabs[i]; + } + throw new Error("no tab " + key); +} +showView(VIEW_DEFAULT, false); +var out = []; +%(presses)s.forEach(function (key) { + var t = tabFor(key); + // The real handler reaches the tab through ev.target.closest. + onTabClick({ target: { closest: function (sel) { return sel === ".ls-view-tab" ? t : null; } } }); + out.push({ + pressed: key, + view: currentView, + hidden: !!feedEl.getAttribute("data-hidden"), + ariaHidden: feedEl.getAttribute("aria-hidden"), + expanded: tabs.map(function (x) { + return x.getAttribute("data-view") + ":" + x.getAttribute("aria-expanded"); + }) + }); +}); +console.log(JSON.stringify(out)); +""" + % { + "keys": json.dumps(_VIEW_KEYS), + "source": source, + "presses": json.dumps(presses), + } + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "toggle.js" + path.write_text(program, encoding="utf-8") + proc = subprocess.run( + [_node(), str(path)], capture_output=True, text=True, timeout=60 + ) + if proc.returncode != 0: + raise Failed("the toggle source threw:\n" + proc.stderr[-2000:]) + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +class TestPressingTheActiveCategoryHidesTheFeed: + """Jay: "pressing on the active category icon on the lock screen hides the + notifications/banners etc. pressing it should have a nice fade in fade out + animation for hiding and restoring the view". + + It is the one thing a lock screen full of cards could not do: see what is + underneath without unlocking or waiting for the screen to blank. + """ + + FIRST = _VIEW_KEYS[0] + OTHER = _VIEW_KEYS[1] + + def test_pressing_the_active_tab_hides_the_feed(self): + out = _run_toggle([self.FIRST]) + assert out[-1]["hidden"] is True + assert out[-1]["view"] == self.FIRST, "hiding must not change the view" + + def test_pressing_it_again_brings_the_feed_back(self): + out = _run_toggle([self.FIRST, self.FIRST]) + assert [step["hidden"] for step in out] == [True, False] + + def test_choosing_a_different_category_restores_the_feed(self): + """Asking for another category means asking to SEE it -- a tap that + switched to a hidden panel would look like a dead screen.""" + out = _run_toggle([self.FIRST, self.OTHER]) + assert out[0]["hidden"] is True + assert out[1]["hidden"] is False + assert out[1]["view"] == self.OTHER + + def test_a_hidden_feed_is_hidden_from_a_screen_reader_too(self): + """Faded is still readable. Opacity alone would leave every card in the + accessibility tree while the screen looks empty.""" + out = _run_toggle([self.FIRST]) + assert out[-1]["ariaHidden"] == "true" + out = _run_toggle([self.FIRST, self.FIRST]) + assert out[-1]["ariaHidden"] == "false" + + def test_only_the_selected_tab_claims_to_be_holding_it(self): + """The other six are not hiding anything, and saying they are would be + a lie to a reader.""" + out = _run_toggle([self.FIRST]) + expanded = [e for e in out[-1]["expanded"] if not e.endswith(":null")] + assert expanded == ["%s:false" % self.FIRST], expanded + + def test_the_fade_is_a_transition_not_a_display_change(self): + """Jay asked for the animation. display:none cannot be transitioned, + and collapsing the column would jump the icon row down the screen + mid-press -- so the state must be opacity, and the row must stay put.""" + css = auth._LOCK_SCREEN_STYLE + assert 'data-hidden="1"' in css + block = css[css.index('.ls-feed[data-hidden="1"]'):] + block = block[: block.index("}")] + assert "opacity: 0" in block + assert "display" not in block + # The transition lives on .ls-feed itself, in its own rule: the state + # rule only names the end point, and a transition declared there would + # animate on the way out and snap on the way back. + assert "transition: opacity" in css + + def test_the_harness_observes_the_defect(self): + """The control: with the repeat press switching to the same view + instead of toggling, every rendered value above is unchanged.""" + out = _run_toggle([self.FIRST, self.FIRST], mutate=True) + assert [step["hidden"] for step in out] == [False, False] diff --git a/tests/test_onscreen_keyboard.py b/tests/test_onscreen_keyboard.py index 84f8aee8b..c6c23167c 100644 --- a/tests/test_onscreen_keyboard.py +++ b/tests/test_onscreen_keyboard.py @@ -636,18 +636,47 @@ async def test_notifications_are_console_only(self, monkeypatch): assert (await auth_mod.lock_notifications(None)).status_code == 403 def test_every_source_jay_asked_for_has_a_stack(self): - from tinyagentos.routes.auth import _demo_notifications + """Jay moved four of the five original stacks out of Alerts: "the alerts + category has some of the old notifications that need moving into the + correct categories". The stacks predate the panels and were written when + Alerts was the only place anything could go -- mail, X and SMS repeated + the mailbox, and the phone stack repeated the missed calls. + + So the sources he asked for are now the ones nothing else can carry, and + this asserts BOTH halves: what Alerts holds, and that it no longer holds + what another panel owns. Asserting only the first would still pass with + every duplicate stack back in place. + """ + from tinyagentos.routes.auth import _demo_notifications, _demo_panels sources = {group["source"] for group in _demo_notifications()} - assert {"mail", "x", "reddit", "phone", "sms"} <= sources + assert {"agent", "system"} <= sources + + # Read off the panels rather than a hand-typed list: a source that moves + # into a panel later is covered without anyone remembering to come here. + owned = { + str(item.get("app", "")).lower() + for key in ("phone", "mailbox", "apps") + for item in _demo_panels()[key] + } + clash = {s for s in sources if s in owned} + assert not clash, "Alerts is duplicating a panel again: %r" % (clash,) def test_items_are_collated_by_source_not_listed_flat(self): """The whole point of the stack: several mails are ONE pile, not three banners pushing the islands off the screen.""" from tinyagentos.routes.auth import _demo_notifications - groups = {g["source"]: g for g in _demo_notifications()} - assert len(groups["mail"]["items"]) >= 3 + groups = _demo_notifications() + + # One stack per source is what "collated" MEANS: two groups with the + # same source are two banners for one pile, which is the flat list this + # is here to rule out. + sources = [g["source"] for g in groups] + assert len(sources) == len(set(sources)), sources + assert max(len(g["items"]) for g in groups) > 1, ( + "no stack has more than one item -- every alert is its own banner" + ) def test_stacks_and_their_items_are_newest_first(self): """A phone orders by arrival. A fixed table order would leave an diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 8a5fc817f..cc4f459b0 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/lock-events", "/auth/lock-power-menu", "/auth/lock-screen-off", "/auth/lock-screen-on", "/auth/lock-brightness", "/auth/lock-torch", "/auth/lock-volume", "/auth/lock-volume-key", "/auth/lock-radios", "/auth/lock-power-action", "/auth/lock-app", "/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..74764ade3 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -3,18 +3,27 @@ import asyncio import html import json +import math +import random import socket from pathlib import Path import logging import os import threading import time +import zlib from collections import OrderedDict from datetime import datetime, timedelta 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, @@ -434,7 +443,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. */ @@ -486,6 +499,34 @@ def count(self, key: str) -> int: overscroll-behavior: contain; } .ls-feed::-webkit-scrollbar { width: 0; height: 0; display: none; } + +/* PRESSING THE ACTIVE CATEGORY CLEARS THE FEED AWAY. Jay asked for it, and it + is the one thing a lock screen full of cards could not do: see the screen + underneath without unlocking or waiting for it to blank. + + Faded, NOT display:none. The row of category icons has to stay exactly where + it is so the same press brings the content back, and a display change would + collapse the column and jump the row down the screen mid-animation. + translateY gives the fade somewhere to go so it reads as the cards dropping + away rather than the screen dimming. + + pointer-events is what makes it honest: an invisible feed must not swallow a + touch. It also hands the unlock swipe back the whole screen, because the + swipe's veto only fires for touches that start inside .ls-feed -- with the + cards gone, a swipe up unlocks from anywhere, which is what an empty screen + should do. */ +.ls-feed { + transition: opacity 260ms cubic-bezier(.2, .8, .2, 1), + transform 260ms cubic-bezier(.2, .8, .2, 1); +} +.ls-feed[data-hidden="1"] { + opacity: 0; + transform: translateY(10px); + pointer-events: none; +} +/* The tab that is holding its content hidden says so rather than looking + identical to one that is showing it. */ +.ls-view-tab[aria-expanded="false"] { opacity: .55; } /* The cut edge. With the bar hidden, a scrolling feed ends in a card sliced clean in half against the unlock bar, which reads as a rendering fault rather than as more content. A fade says "this continues". @@ -593,6 +634,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. */ @@ -664,9 +841,518 @@ 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; } + +/* THE VOLUME BEZEL. Right edge, vertical, level with the rocker. */ +.ls-vol { + position: fixed; right: 10px; top: 50%; z-index: 80; + transform: translate(120%, -50%); + display: flex; flex-direction: column; align-items: center; gap: 10px; + padding: 14px 10px; border-radius: 22px; + background: rgba(24,24,27,0.86); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + box-shadow: 0 12px 34px -10px rgba(0,0,0,0.85); + opacity: 0; + transition: transform 260ms cubic-bezier(0.32,0.72,0,1), opacity 200ms ease; + pointer-events: none; /* a heads-up, never a target */ +} +.ls-vol[data-on="1"] { transform: translate(0, -50%); opacity: 1; } +.ls-vol-track { + width: 8px; height: 150px; border-radius: 999px; + background: rgba(255,255,255,0.18); + display: flex; align-items: flex-end; overflow: hidden; +} +.ls-vol-fill { + display: block; width: 100%; height: var(--ls-vol, 50%); + border-radius: 999px; background: #fff; + transition: height 140ms ease; +} +.ls-vol-num { + font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.8); + font-variant-numeric: tabular-nums; +} +.ls-vol-note { + max-width: 76px; font-size: 10px; line-height: 1.25; text-align: center; + color: rgba(255,176,32,0.92); +} + +/* THE AGENT CAROUSEL -- RADIAL, pivoting on the volume rocker. + * + * Jay: "left edge thumb pivot around the button". So the faces sit on an ARC + * swept from the left edge at the rocker's height, not in a vertical strip. + * The thumb stays on the button and the agents come to it, which is the whole + * point of pivoting there rather than centring the arc on the screen. + * + * --ls-car-pivot is where the rocker is, as a share of screen height. 34% is a + * STARTING GUESS, not a measurement -- unlike the camera cutout, there is no + * vendor file that gives the button's position, so this is the one number here + * that wants a human to look at it. It is a single custom property so nudging + * it is a one-line change. + */ +:root { --ls-car-pivot: 34%; --ls-car-radius: 104px; } +.ls-carousel { + position: fixed; left: 0; top: 0; bottom: 0; right: 0; z-index: 80; + opacity: 0; pointer-events: none; + transition: opacity 200ms ease; +} +.ls-carousel[data-on="1"] { opacity: 1; } +/* THE SCREEN BEHIND THE ARC IS BLURRED. Jay asked for it, and it earns its + place: the faces are small, low-contrast circles sitting over a feed of cards + and text, and without separation the focused one is genuinely hard to pick + out at a glance -- which is the one thing a chooser driven by a physical key + has to get right, because your eye is not already on the screen. + * + * Applied to .lockscreen, which is a SIBLING of the carousel and the scrim, so + * neither the arc nor the dim gets blurred with it. The existing sheets blur + * their chrome piecemeal (.ls-head, .ls-statusbar, .ls-weather) because a sheet + * only covers the bottom; this covers the middle of the screen, so the whole + * surface goes. + * + * NOT applied to the volume bezel, deliberately: that is a transient heads-up + * for a key you are already holding, and blurring the entire screen to show a + * volume level would be heavy-handed for it. */ +.lockscreen[data-radial="1"] { + filter: blur(7px); + transition: filter 260ms ease; +} +/* Summoned onto a DARK panel: the lock screen is not blurred, it is gone. On + OLED an unlit pixel emits nothing, so the faces sit on real black rather than + on a dimmed photograph of a lock screen -- which is the effect Jay was after + and the one thing an OLED does that no amount of blur imitates. + visibility rather than display:none, so nothing reflows on the way in. */ +.lockscreen[data-radial="dark"] { + visibility: hidden; + transition: none; +} +.lockscreen[data-radial="dark"] ~ .ls-scrim { background: #000; opacity: 1; } + +/* BLANKED: what the panel holds in its scanout buffer while it is off. + * + * No transition on the way IN -- the panel is about to go down and there is no + * time for one; the point is that the last painted frame is black. Coming back + * it fades, so an ordinary wake rises out of black rather than snapping on, + * which is both nicer and the same motion the arc uses. + * + * opacity, not visibility: it animates, and an OLED showing opacity 0 over a + * black page body is emitting nothing anyway. */ +.lockscreen[data-blanked="1"] { + opacity: 0; + transition: none; +} +/* THE BODY GOES BLACK TOO, and this is the bit I kept missing. + * + * `body` carries a dark GREY GRADIENT (#141415 -> #202024) for the ordinary + * sign-in card, and .lockscreen has no background of its own. So hiding the + * lock screen revealed that gradient, which is exactly what Jay reported twice: + * "it shows the lock screen background grey", and "it even flashes sometimes on + * rotary start/open" -- the flash being the frame where the lock screen was + * hidden and the black scrim had not painted yet. + * + * Hiding a transparent layer over grey shows grey. The layer underneath has to + * be black, so it is, in the same style recalculation -- there is no frame in + * between for the gradient to appear in. + * + * Specificity does the work: body.ls-black (0,1,1) beats body (0,0,1), so no + * !important is needed. */ +body.ls-black { background: #000; } +.lockscreen { + transition: opacity 320ms ease; +} +@media (prefers-reduced-motion: reduce) { + .lockscreen { transition: none; } +} + +/* EMERGING FROM THE BLACK. Jay: "it would be nice if the the rotary menu could + * have an appear effect like fading into view out of the deep black oled + * display." + * + * Slower and softer than the lit-screen case on purpose. Over a blurred lock + * screen the arc only has to arrive; over true black it is the ONLY thing on + * the panel, so the eye follows it completely and a 200ms snap reads as a + * flash. This gives it time to resolve out of nothing. + * + * The scale grows from the PIVOT, not the centre, so it unfurls from under the + * thumb rather than swelling out of the middle of a dark screen -- the pivot is + * the whole conceit of this layout and the animation should say so. + * + * Opacity is eased out of zero slowly at first (the cubic starts shallow): + * on OLED the first few percent of brightness off true black is the most + * visible step there is, and a linear fade shows a hard edge appearing. */ +.lockscreen[data-radial="dark"] ~ #ls-carousel { + transform-origin: 0 var(--ls-car-pivot); + transform: scale(0.9); + transition: opacity 520ms cubic-bezier(0.4, 0, 0.2, 1), + transform 620ms cubic-bezier(0.22, 1, 0.36, 1); +} +.lockscreen[data-radial="dark"] ~ #ls-carousel[data-on="1"] { + transform: scale(1); +} +/* The faces arrive just behind the ring they sit on, so the arc reads as a + thing that appeared and then filled, rather than everything at once. */ +.lockscreen[data-radial="dark"] ~ #ls-carousel .ls-face { + transition: transform 420ms cubic-bezier(0.32,0.72,0,1), + opacity 480ms ease 90ms, + box-shadow 180ms ease; +} +/* The banner last. It is text, and text arriving first on a black screen is + what makes an animation feel like a page load. */ +.lockscreen[data-radial="dark"] ~ #ls-carousel .ls-carousel-banner { + transition: opacity 420ms ease 180ms; +} +.lockscreen[data-radial="dark"] ~ #ls-carousel:not([data-on="1"]) .ls-carousel-banner { + opacity: 0; +} +@media (prefers-reduced-motion: reduce) { + .lockscreen[data-radial="dark"] ~ #ls-carousel, + .lockscreen[data-radial="dark"] ~ #ls-carousel[data-on="1"] { + transform: none; transition: opacity 200ms ease; + } + .lockscreen[data-radial="dark"] ~ #ls-carousel .ls-face, + .lockscreen[data-radial="dark"] ~ #ls-carousel .ls-carousel-banner { + transition: none; + } +} +@media (prefers-reduced-motion: reduce) { + .lockscreen[data-radial="1"] { transition: none; } +} +/* The pivot itself: a zero-size origin on the left edge at the rocker's + height. Every face is placed relative to THIS, so moving the pivot moves the + whole arc and nothing else needs to know. */ +.ls-carousel-strip { + position: absolute; left: 0; top: var(--ls-car-pivot); + width: 0; height: 0; +} +/* Each face rides the arc. The double rotation is what keeps a face UPRIGHT + while sitting on a curve: rotate to its angle, push out along the radius, + then rotate back by the same amount. Without the second rotation the avatars + tilt, which on a ring of faces reads as a rendering fault rather than style. */ +.ls-face { + position: absolute; left: 0; top: 0; + width: 46px; height: 46px; margin: -23px; + border-radius: 50%; + background: rgba(255,255,255,0.1) center/cover no-repeat; + display: flex; align-items: center; justify-content: center; + font-size: 14px; font-weight: 700; color: rgba(255,255,255,0.7); + transform: + rotate(var(--a, 0deg)) + translateX(var(--ls-car-radius)) + rotate(calc(-1 * var(--a, 0deg))) + scale(var(--s, 0.82)); + opacity: var(--o, 0.35); + transition: transform 300ms cubic-bezier(0.32,0.72,0,1), opacity 220ms ease, + box-shadow 180ms ease; +} +.ls-face[data-focus="1"] { + box-shadow: 0 0 0 2px rgba(255,255,255,0.9), 0 8px 22px -6px rgba(0,0,0,0.8); + color: #fff; +} +/* A faint arc behind the faces, so the ring reads as one object rather than + scattered dots. Drawn as a ring clipped to the pivot side. */ +.ls-carousel-arc { + position: absolute; left: 0; top: var(--ls-car-pivot); + width: calc(var(--ls-car-radius) * 2); height: calc(var(--ls-car-radius) * 2); + margin: calc(var(--ls-car-radius) * -1); + border-radius: 50%; + border: 1px solid rgba(255,255,255,0.1); + pointer-events: none; +} +/* The banner sits OUTSIDE the arc, level with the pivot, so the name is beside + the focused face rather than under the thumb. */ +.ls-carousel-banner { + position: absolute; top: var(--ls-car-pivot); + left: calc(var(--ls-car-radius) + 46px); + transform: translateY(-50%); + max-width: 200px; + padding: 12px 15px; border-radius: 20px; + background: rgba(24,24,27,0.9); + backdrop-filter: blur(26px) saturate(1.3); + -webkit-backdrop-filter: blur(26px) saturate(1.3); + box-shadow: 0 14px 40px -12px rgba(0,0,0,0.85); +} +.ls-carousel-name { font-size: 16px; font-weight: 700; color: #fff; } +.ls-carousel-role { + margin-top: 1px; font-size: 11px; color: rgba(255,255,255,0.62); + text-transform: uppercase; letter-spacing: 0.05em; +} +.ls-carousel-ptt { margin-top: 8px; font-size: 12px; color: rgba(255,255,255,0.45); } +.ls-carousel[data-talking="1"] .ls-carousel-ptt { color: #6ee787; font-weight: 600; } +.ls-carousel[data-talking="1"] .ls-face[data-focus="1"] { + box-shadow: 0 0 0 3px #30d158; + animation: ls-ptt 1.1s ease-in-out infinite; +} +@keyframes ls-ptt { + 0%, 100% { box-shadow: 0 0 0 3px #30d158; } + 50% { box-shadow: 0 0 0 8px rgba(48,209,88,0.32); } +} +@media (prefers-reduced-motion: reduce) { + .ls-vol, .ls-carousel, .ls-face, .ls-vol-fill { transition: none; } + .ls-carousel[data-talking="1"] .ls-face[data-focus="1"] { animation: none; } +} + +/* TORCH AND CAMERA, flanking the unlock bar. + * + * Round, dim, and the same size as each other: they are landmarks found by + * position rather than read, which is why they sit at the edges with the bar + * between them. Big targets because they are pressed with a thumb, often in + * the dark -- the torch especially, which is the one control on this screen + * someone reaches for precisely when they cannot see. */ +.ls-unlock-row { + display: flex; align-items: center; justify-content: center; gap: 14px; + width: 100%; max-width: var(--ls-card-w); margin: 0 auto; +} +.ls-unlock-row .ls-unlock-btn { flex: 1; min-width: 0; } +.ls-quick { + flex: none; width: 46px; height: 46px; padding: 0; + border: 0; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.82); + transition: background 200ms ease, color 200ms ease, transform 140ms ease; +} +.ls-quick svg { + width: 21px; height: 21px; fill: none; stroke: currentColor; + stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; +} +.ls-quick:active { transform: scale(0.92); } +.ls-quick:focus-visible { outline: 3px solid #4c9aff; outline-offset: 3px; } +/* Lit: the torch inverts, the way it does on every phone, so its state is + unmistakable from the corner of the eye in a dark room. */ +.ls-quick[aria-pressed="true"] { background: #fff; color: #111; } +/* Unavailable rather than hidden. A missing control is a thing the user hunts + for; a dimmed one answers the question. */ +.ls-quick[disabled] { opacity: 0.38; } +.ls-quick[data-note]::after { + content: attr(data-note); + position: absolute; bottom: 54px; left: 50%; transform: translateX(-50%); + white-space: nowrap; padding: 6px 10px; border-radius: 10px; + background: rgba(24,24,27,0.94); color: rgba(255,255,255,0.8); + font-size: 11px; font-weight: 500; +} +.ls-quick { position: relative; } + +/* THE PULL-DOWN SHADE, from the TOP edge -- the one surface on this screen that + does not come from the bottom, because that is where the gesture starts. It + deliberately does NOT cover the whole screen: a shade that fills the display + for one slider reads as a mode you have to escape, and the clock staying + visible behind it is what makes it feel like a shade rather than a page. */ +.ls-shade { + position: fixed; top: 0; left: 0; right: 0; z-index: 70; + padding: calc(env(safe-area-inset-top, 0px) + 8px) 12px 14px; + transform: translateY(-101%); + transition: transform 340ms cubic-bezier(0.32, 0.72, 0, 1); +} +.ls-shade[hidden] { display: none; } +.lockscreen[data-sheet="shade"] ~ #ls-shade { transform: translateY(0); } +.ls-shade-inner { + position: relative; + margin: 0 auto; width: 100%; max-width: var(--ls-card-w); + padding: 16px 16px 20px; + border-radius: 0 0 26px 26px; + background: rgba(24, 24, 27, 0.9); + box-shadow: 0 20px 50px -14px rgba(0, 0, 0, 0.9); + backdrop-filter: blur(30px) saturate(1.3); + -webkit-backdrop-filter: blur(30px) saturate(1.3); +} +.ls-shade-toggles { + display: flex; gap: 10px; padding-bottom: 14px; +} +.ls-toggle { + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 12px 6px; border: 0; border-radius: 20px; + font: inherit; font-size: 11px; font-weight: 600; + background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); + transition: background 200ms ease, color 200ms ease; +} +/* ON is a filled tile, not a tick: at a glance across a room the FILL is what + reads, and these are glanced at rather than studied. */ +.ls-toggle[aria-pressed="true"] { background: #4c9aff; color: #fff; } +.ls-toggle:focus-visible { outline: 3px solid #4c9aff; outline-offset: 2px; } +.ls-toggle svg { width: 22px; height: 22px; fill: none; stroke: currentColor; + stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +/* Mid-flight. A radio takes a moment to come up, and a switch that snapped back + to its old position while waiting would read as having refused the tap. */ +.ls-toggle[data-busy="1"] { opacity: 0.55; } +.ls-shade-row { display: flex; align-items: center; gap: 12px; } +.ls-shade-icon { + flex: none; width: 22px; height: 22px; + fill: none; stroke: rgba(255,255,255,0.8); + stroke-width: 1.7; stroke-linecap: round; +} +.ls-shade-value { + flex: none; min-width: 42px; text-align: right; + font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.75); + font-variant-numeric: tabular-nums; +} +/* A tall track and a big thumb: this is dragged with a thumb in the dark, and + it is the control someone reaches for precisely when they cannot see well. */ +.ls-shade-slider { + flex: 1; min-width: 0; height: 34px; margin: 0; + -webkit-appearance: none; appearance: none; background: none; +} +.ls-shade-slider::-webkit-slider-runnable-track { + height: 10px; border-radius: 999px; background: rgba(255,255,255,0.18); +} +.ls-shade-slider::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 26px; height: 26px; margin-top: -8px; + border-radius: 50%; background: #fff; + box-shadow: 0 2px 8px -1px rgba(0,0,0,0.6); +} +.ls-shade-slider:focus-visible { outline: 3px solid #4c9aff; outline-offset: 4px; border-radius: 999px; } +.ls-shade-note { + margin-top: 10px; font-size: 12px; line-height: 1.35; + color: rgba(255,176,32,0.9); +} +/* The grip, echoing the unlock grabber at the other end of the screen so the + two read as the same vocabulary. */ +.ls-shade-grip { + position: absolute; left: 50%; bottom: 7px; transform: translateX(-50%); + width: 38px; height: 4px; border-radius: 999px; + background: rgba(255,255,255,0.28); +} +@media (prefers-reduced-motion: reduce) { + .ls-shade { transition: none; } +} + +/* THE POWER MENU. Jay: "I rather the power button menu be buttons centred on + the screen against blurred background like iOS." + * + * So it is NOT a bottom sheet. It is a centred dialog that scales up out of the + * blur -- the same shape iOS uses for an alert, and the right one here: this is + * a modal question with four answers, not a drawer of content you might browse. + * Centring also puts the targets under the thumb from either hand, which a + * bottom sheet does not once it is five rows tall. + * + * The backdrop blur is already there: `.lockscreen:not([data-sheet="none"])` + * blurs the chrome and `.ls-scrim` darkens behind it, both driven by the same + * data-sheet attribute this rides on. */ +.ls-modal { + position: fixed; inset: 0; z-index: 60; + display: flex; align-items: center; justify-content: center; + padding: 24px; + /* Not shown until the attribute says so. pointer-events:none while hidden so + the invisible full-screen box cannot swallow a touch meant for the page -- + a modal that is closed but still eating input is indistinguishable from a + frozen screen. */ + opacity: 0; pointer-events: none; + transform: scale(0.92); + transition: opacity 200ms ease, transform 260ms cubic-bezier(0.32, 0.72, 0, 1); +} +.ls-modal[hidden] { display: none; } +/* Set for the duration of a close that happens while the panel is powering + down. Nothing is compositing then, so an animated close has nowhere to run + and would replay on wake -- the user sees the menu close half a second after + the screen comes back, which reads as the phone catching up with itself. */ +.lockscreen[data-instant="1"] ~ .ls-modal, +.lockscreen[data-instant="1"] ~ .ls-shade, +.lockscreen[data-instant="1"] ~ .ls-sheet, +/* The volume surfaces too. They are siblings of .lockscreen, so hiding the + lock screen never touched them -- a panel blanking mid-fade kept a half-lit + arc in its buffer and showed it on the next wake. */ +.lockscreen[data-instant="1"] ~ #ls-vol, +.lockscreen[data-instant="1"] ~ #ls-carousel, +.lockscreen[data-instant="1"] ~ #ls-carousel .ls-face, +.lockscreen[data-instant="1"] ~ #ls-carousel .ls-carousel-banner { transition: none; } +.lockscreen[data-sheet="power"] ~ #ls-power { + opacity: 1; pointer-events: auto; transform: scale(1); +} +.ls-modal-card { + width: 100%; max-width: var(--ls-card-w); + display: flex; flex-direction: column; gap: 8px; + padding: 20px 16px 14px; + border-radius: 28px; + background: rgba(28, 28, 30, 0.78); + box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.9); + backdrop-filter: blur(34px) saturate(1.35); + -webkit-backdrop-filter: blur(34px) saturate(1.35); +} +.ls-modal-title { + text-align: center; font-size: 19px; font-weight: 700; color: #fff; +} +.ls-modal-sub { + text-align: center; margin-top: -4px; padding-bottom: 4px; + font-size: 12px; color: rgba(255,255,255,0.5); +} +/* Cancel, set apart from the actions above it. iOS puts the safe choice last + and makes it the plainest thing on the card; the dangerous ones should never + be what the thumb finds by default. */ +.ls-modal-cancel { + margin-top: 4px; padding: 13px; border: 0; border-radius: 16px; + font: inherit; font-size: 16px; font-weight: 600; + background: rgba(255,255,255,0.14); color: #fff; +} +.ls-modal-cancel:focus-visible { outline: 3px solid #4c9aff; outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { + .ls-modal { transition: none; transform: none; } + .lockscreen[data-sheet="power"] ~ #ls-power { transform: none; } +} + +/* The action rows. 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: 2px 0 4px; } +.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; } +/* Emergency is not "destructive", it is URGENT: the whole row carries the + colour rather than just the label, so it is findable without reading. */ +.ls-power-item[data-emergency="1"] { + background: rgba(255,59,48,0.22); color: #ff8a80; +} +.ls-power-item[data-emergency="1"] .ls-power-glyph { background: rgba(255,59,48,0.28); } +.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); @@ -1039,6 +1725,13 @@ def count(self, key: str) -> int: .ls-sheet[hidden] { display: none; } .lockscreen[data-sheet="chat"] ~ #ls-chat, .lockscreen[data-sheet="decision"] ~ #ls-decision { transform: translateY(0); } +/* ⚠ EVERY sheet needs a line here. `.ls-sheet` rests at translateY(101%) and + only the names listed are pulled up, while the backdrop blur is driven by the + generic `:not([data-sheet="none"])` rules. So a sheet that is opened but not + named here produces EXACTLY what Jay saw: "Power button blurs screen but no + buttons show" -- the chrome reacts, the sheet stays off-screen, and nothing + errors. Same shape as the panels that painted 354 rows while `hidden`: a new + element added to a system whose visibility is a hand-written list of names. */ /* The passcode sheet is the sign-in shell itself, so it gets the same motion rather than a second implementation of "a sheet". */ .lockscreen .ls-foot { @@ -1192,7 +1885,19 @@ def count(self, key: str) -> int: } .ls-voice-text[data-error="1"] { font-size: 14px; color: rgba(255,176,32,0.92); } .ls-voice-acts { display: flex; gap: 10px; padding-top: 2px; } -.lockscreen[data-sheet="voice"] ~ #ls-voice { transform: translateY(0); } +/* The dictation dialog is a MODAL now, not a sheet, so it scales up out of the + blur like the power menu instead of sliding from the bottom edge. Its reveal + rule has to live beside the other modal one, or it opens invisibly -- the + failure the sheet-name test exists to catch. */ +.lockscreen[data-sheet="voice"] ~ #ls-voice { + opacity: 1; pointer-events: auto; transform: scale(1); +} +.ls-modal-voice .ls-voice-head { + display: flex; align-items: center; gap: 11px; padding-bottom: 4px; +} +.ls-modal-voice .ls-voice-who { min-width: 0; flex: 1; text-align: left; } +.ls-modal-voice .ls-sheet-close { flex: none; } +.ls-modal-voice .ls-voice-acts { padding-top: 4px; } /* Force-touch feel: the island sinks under the finger, then pops as it opens. Without the sink there is no feedback that a HOLD is doing anything, and the @@ -1561,6 +2266,14 @@ def _pin_panel_html(next_url: str, keypad: bool = False) -> str: + + + + + + @@ -1575,13 +2288,6 @@ def _pin_panel_html(next_url: str, keypad: bool = False) -> str: - - - - - - """ @@ -1615,13 +2321,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 +2399,23 @@ def _lock_head_html() -> str:
+ role="tabpanel" aria-labelledby="ls-tab-alerts" aria-label="Alerts" hidden> + +
+ + - {_FRAMEWORK_SPRITE} {_VIEW_SPRITE} @@ -1706,11 +2423,29 @@ def _lock_head_html() -> str:
- + +
+ + + +
""" @@ -1752,6 +2487,74 @@ def _lock_tail_html() -> str: + + + + + + + + + + + + -