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: