Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,48 @@ header .mode {
* pill is a stray mark, and an unreadable config should cost the badge rather than guess a
* mode. Same pairing as `#build` in the footer, which is also empty until the same read. */
header .mode:empty { display: none; }

/* ── the deployment card (#755) ───────────────────────────────────────────────────────────────
*
* The badge is the `<summary>` of a native disclosure, so the identifying `--db`/`--config` pair
* -- which `modeBadge` also writes into a `title`, and `title` is hover-only -- is reachable by
* tap on a device that has no pointer.
*
* The whole disclosure hides when the badge is empty, matching `header .mode:empty`: a config
* that could not be read is not a deployment called "unknown", and an empty pill with a
* disclosure triangle beside it would be worse than nothing. */
header #deployment:has(> summary:empty) { display: none; }

header #deployment { position: relative; }

/* The default triangle is removed on BOTH spellings -- `::-webkit-details-marker` for older
* WebKit, `list-style` for everything current -- because the badge is a pill and a marker glued
* to it reads as a chevron on a button, which is the dropdown this is deliberately not. */
header #deployment > summary { cursor: pointer; list-style: none; }
header #deployment > summary::-webkit-details-marker { display: none; }

/* Anchored to the badge and OUT of flow: opening it must not reflow the header, which on a narrow
* screen would push the nav around under the reader's finger mid-tap. */
header #deployment > #deployment-card {
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 2;
min-width: min(22rem, 88vw);
background: var(--bg);
border: 1px solid var(--control-line);
border-radius: 6px;
padding: 0.6rem;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
text-align: left;
}

/* The paths are the point of the card and they are long. Wrapping beats a horizontal scrollbar
* inside a popover no wider than a phone. */
header #deployment > #deployment-card .v {
overflow-wrap: anywhere;
font-size: 0.85em;
}
/* The emphasis table `render.js::MODE_CLASS` places: paper is the quiet one (muted, like any
* label), confirm and live carry the accent, because a deployment that can place orders should
* sit up in the header. NOT `--good`/`--bad` -- those are `state`'s vocabulary and there is no
Expand Down
29 changes: 24 additions & 5 deletions keel/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
(`:empty` in `keel.css`), because an empty pill is a stray mark, and an unreadable config
should cost the badge rather than guess a mode.
-->
<span class="right">
<div class="right">
<!--
THE SESSION CHIP (#704): profile · mode · equity state, on every view.

Expand All @@ -195,11 +195,30 @@
Both halves empty in the markup and hidden while empty (`:empty` in `keel.css`), like
`#mode-badge` and `#build`: a deployment with no readable name is not one called "unknown".
-->
<span id="session-chip">
<!--
THE DEPLOYMENT CARD (#755): the badge is the SUMMARY of a native disclosure.

`modeBadge` writes the identifying `--db`/`--config` pair into a `title`, which is a HOVER
tooltip -- and there is no hover on a phone, the exact device Phase B is for. So the same
facts get a tap-to-reveal path here.

`<details>` and NOT a button with a handler: it is keyboard-operable, touch-operable and
announced with nothing bound, which is what lets the card pass the same no-interactive-node
test the chip and the banner answer to (`venueCard` uses the element the same way). Nothing
inside it changes anything; switching mode or profile stays a terminal ceremony.

`#session-chip` and `.right` are DIVs rather than spans because `<details>` is flow content
and a `<span>` may only hold phrasing content. Both already set their own `display` in
`keel.css`, so the layout is unchanged by the tag.
-->
<div id="session-chip">
<span id="session-profile" class="sessionpart"></span>
<span id="mode-badge" class="pill mode"></span>
<details id="deployment">
<summary id="mode-badge" class="pill mode"></summary>
<div id="deployment-card"></div>
</details>
<span id="session-equity" class="sessionpart"></span>
</span>
</div>

<!--
THE THEME TOGGLE (#597): two states, and the stylesheet is the state machine.
Expand Down Expand Up @@ -236,7 +255,7 @@
<path d="M20.2 13.2A8.2 8.2 0 1 1 10.8 3.8a6.5 6.5 0 0 0 9.4 9.4Z"/>
</svg>
</button>
</span>
</div><!-- .right -->
</header>

<!--
Expand Down
7 changes: 7 additions & 0 deletions keel/web/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
gatesView,
plansView,
insightsView,
deploymentCard,
modeBadge,
paperBanner,
sessionChip,
Expand Down Expand Up @@ -153,6 +154,8 @@ const updateNode = must("update");
const modeNode = must("mode-badge");
/** The session chip's profile half (#704) -- the database's own name, filled from the boot read. */
const sessionProfileNode = must("session-profile");
//: #755. The badge is the disclosure's summary; this is what the disclosure reveals.
const deploymentCardNode = must("deployment-card");
/** The session chip's equity-state half (#704). Genuinely absent on a deployment that has never
* flipped modes, which is why it is a `Field` and not a bare word. */
const sessionEquityNode = must("session-equity");
Expand Down Expand Up @@ -1537,6 +1540,10 @@ void read("config").then((reading) => {
// process serves cannot change without a restart, and filling the chip here is what puts it on
// every view rather than only where a status report happens to load.
sessionChip(sessionProfileNode, sessionEquityNode, config);
// #755: the same boot read, for the same reason -- and the card must be filled even
// though it is closed, because a `<details>` opens with no JavaScript involved and
// there is no event to fill it on.
deploymentCard(deploymentCardNode, config);
paperBanner(modeBannerNode, config);
registerWorker(config);
show(booted, false);
Expand Down
51 changes: 51 additions & 0 deletions keel/web/static/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -3355,6 +3355,57 @@ export function modeBadge(node, config) {
node.title = (config.db_path || "").concat(" · config ", config.config_path || "");
}

//: What the card says where a control would be. Static copy, not payload prose: `paperBanner`'s
//: sentence arrives from Python because that function CHOOSES between sentences on the basis of a
//: config, which is a judgement. This one is the same words on every deployment, so it is UI copy
//: in the class of `plansView`'s subtitle, and Rule 2 has nothing to say about it.
const SWITCHING_NOTE = "Mode and profile switching are terminal ceremonies with a runbook. ".concat(
"This console reports the deployment it was pointed at and cannot change it.",
);

/**
* The deployment identity card (#755) -- the `title` tooltip's contents, reachable by touch.
*
* `modeBadge` writes `--db` and `--config` into a `title`, and `title` is a HOVER tooltip. There
* is no hover on a phone, which is the device #648/#656 exist for, so the half of the badge that
* actually IDENTIFIES a deployment was unreachable exactly where the terminal is least available.
* The mode word alone identifies nothing: two paper deployments both say `paper`, and a live
* sandbox and a paper-equities profile can both say `confirm`.
*
* **It reveals and it does not offer.** The disclosure is `<details>` in the shell, so there is no
* handler to bind and no button to build, and this function is held to the same
* no-interactive-node test as `sessionChip` and `paperBanner` rather than exempted from it. A
* "switch to live" affordance arriving through a door marked accessibility is still the growth
* funnel the chip's docstring refuses.
*
* The origin comes from `window.location`, not the payload -- it is a fact about where this
* browser is, and in an installed app there is no address bar to read it from. `timelineView`
* takes it from the same place for the same reason.
*
* Empty when the config could not be read, matching `modeBadge`: an absent answer is not `paper`,
* and a card naming a deployment nothing could identify would be inventing one.
*
* @param {HTMLElement} node
* @param {any} config `/api/config`'s `data`, or `null`.
*/
export function deploymentCard(node, config) {
const mode = config && typeof config.mode === "string" ? config.mode : "";
if (!mode) {
node.replaceChildren();
return;
}
node.replaceChildren(
gridCard([
kv("deployment", config.profile || "\u2014"),
kv("mode", mode),
kv("config", config.config_path || "\u2014"),
kv("database", config.db_path || "\u2014"),
kv("origin", window.location.origin),
]),
el("p", "muted", SWITCHING_NOTE),
);
}

/**
* The session chip's two halves, from `/api/config` (#704).
*
Expand Down
13 changes: 11 additions & 2 deletions tests/web/test_client_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,11 @@ def test_the_header_carries_a_theme_toggle_and_a_mode_badge() -> None:
f"an icon that is not token-coloured or is self-announcing: {icon}"
)

assert '<span id="mode-badge" class="pill mode"' in html, "the mode badge is missing"
# A `<summary>` since #755, not a `<span>`: the badge is the summary of the deployment card's
# native disclosure, which is what gives a touch device a path to the `--db`/`--config` pair
# that `modeBadge` also writes into a hover-only `title`. The id and both classes are
# unchanged, and they are what `modeBadge` and `header .mode:empty` actually key on.
assert '<summary id="mode-badge" class="pill mode"' in html, "the mode badge is missing"
# Empty in the markup, like #build: filled from the boot config read, hidden until it is.
assert html.count('id="mode-badge"') == 1

Expand Down Expand Up @@ -935,6 +939,9 @@ def test_the_theme_choice_is_spelled_where_it_is_stored() -> None:
# empty forever, which is precisely the failure named below.
("sessionChip", "config"),
("paperBanner", "config"),
# #755's card, registered in the same commit that adds it, for the reason above: it reads
# four config keys and a rename on either side would leave it rendering em-dashes forever.
("deploymentCard", "config"),
)


Expand Down Expand Up @@ -2084,7 +2091,9 @@ def test_neither_the_chip_nor_the_banner_builds_anything_clickable() -> None:
either. So neither function may create an interactive node or bind a handler.
"""
source = _source("render.js")
for name in ("sessionChip", "paperBanner"):
# #755's `deploymentCard` joins them rather than getting an exemption: it is the surface where
# a reader looks for the control, which makes it the likeliest place for one to be added.
for name in ("sessionChip", "paperBanner", "deploymentCard"):
body = _function_body(source, name)
for forbidden in _INTERACTIVE_TOKENS:
assert forbidden not in body, f"{name} builds something interactive: {forbidden}"
Expand Down
112 changes: 112 additions & 0 deletions tests/web/test_deployment_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""The deployment identity card: "which deployment is this" without a pointer (#755).

`modeBadge` puts the mode WORD in the badge and the identifying `--db`/`--config` pair in a
`title` attribute. `title` renders as a hover tooltip, and there is no hover on a touch device --
so on the exact device Phase B (#648, #656) is built for, the half of the badge that distinguishes
two paper deployments, or paper from live when both read `confirm`, is unreachable.

**This adds no way to CHANGE anything, and the tests below are mostly about that.** Switching mode
or profile is a config-file edit plus a typed terminal ceremony; `render.js`'s own chip docstring
refuses the control and `test_neither_the_chip_nor_the_banner_builds_anything_clickable` pins the
refusal. A disclosure that reveals read-only facts is not that control, and the tests here hold it
to the same standard rather than exempting it.

A browser cannot run here, so these are source assertions -- see `test_client_assets`'s docstring
for the standing argument, and note the same comment-stripping discipline: a scan that a docstring
can satisfy is a scan that proves nothing.
"""

from __future__ import annotations

import re

from keel.web import staticfiles
from tests.web.test_client_assets import _INTERACTIVE_TOKENS, _function_body, _source

_RENDER = _source("render.js")
_MAIN = (staticfiles.STATIC_ROOT / "js" / "main.js").read_text()
_INDEX = (staticfiles.STATIC_ROOT / "index.html").read_text()
_CSS = (staticfiles.STATIC_ROOT / "css" / "keel.css").read_text()
_CSS_RULES = re.sub(r"/\*.*?\*/", "", _CSS, flags=re.S)


def test_the_card_exists_and_is_exported() -> None:
assert "export function deploymentCard(" in _RENDER


def test_the_shell_actually_calls_it() -> None:
"""An uncalled renderer still READS the keys it reads, so the config-parity scan passes over
one that nothing mounts. #704's lesson, applied on the way in rather than after a review."""
assert "deploymentCard" in _MAIN, "render.js exports it and the shell never mounts it"
assert _MAIN.count("deploymentCard(") >= 1


def test_the_card_names_every_fact_that_identifies_a_deployment() -> None:
"""The mode word alone does not identify anything: two paper deployments both say `paper`,
and a live sandbox and a paper-equities profile can both say `confirm`. The pair that
distinguishes them is the point of the card."""
body = _function_body(_RENDER, "deploymentCard")
for key in ("config.db_path", "config.config_path", "config.mode", "config.profile"):
assert key in body, f"the card does not name {key}"


def test_the_card_builds_nothing_that_could_change_anything() -> None:
"""Held to the SAME list as the chip and the banner, deliberately.

A card that reveals facts is the whole request; a card that grew a "switch to live" affordance
would be the growth funnel `sessionChip`'s docstring refuses, arriving through a door marked
accessibility.
"""
body = _function_body(_RENDER, "deploymentCard")
for forbidden in _INTERACTIVE_TOKENS:
assert forbidden not in body, f"deploymentCard builds something interactive: {forbidden}"


def test_the_disclosure_is_native_and_needs_no_handler() -> None:
"""`<details>`/`<summary>` is keyboard-operable, touch-operable and announced, with no
listener to bind and no button to build -- which is what lets this pass the test above.

`venueCard` already uses the same element for the same reason, so this is the established
pattern here rather than a new one."""
assert 'id="deployment"' in _INDEX
assert "<details" in _INDEX, "the card is not a native disclosure"
assert "<summary" in _INDEX


def test_the_badge_still_owns_the_mode_word_and_keeps_its_tooltip() -> None:
"""#755 asks for the touch path and says not to remove the pointer one: `title` works for
keyboard and desktop and costs nothing to keep."""
badge = _function_body(_RENDER, "modeBadge")
assert "node.title" in badge, "the hover tooltip was removed rather than supplemented"
assert "config.mode" in badge or "config && typeof config.mode" in badge


def test_the_card_survives_navigation_because_it_sits_outside_the_view() -> None:
"""`#view` is replaced wholesale on every route change. The same argument the chip and the
banner already answer to."""
view_at = _INDEX.index('id="view"')
assert _INDEX.index('id="deployment"') < view_at, "the card is inside #view and gets repainted"


def test_the_card_says_that_switching_is_not_available_here() -> None:
"""The one sentence that turns an absence into a statement. Without it the card reads as an
unfinished settings panel; with it, the refusal is legible where someone would look for the
control."""
body = _function_body(_RENDER, "deploymentCard")
assert "SWITCHING_NOTE" in body, "the card places no such sentence"

# And the constant it places actually says it. Asserting the reference alone would pass over
# a `SWITCHING_NOTE` that had been emptied or reworded into an invitation -- the same gap
# `test_a_click_is_acknowledged_without_claiming_arrival` closes for `markPending`.
note = re.search(r"const SWITCHING_NOTE =(.+?);", _RENDER, flags=re.S)
assert note, "SWITCHING_NOTE is not defined in render.js"
words = note.group(1)
assert "terminal" in words and "cannot change" in words, words
for invitation in ("upgrade", "Go live", "switch to live", "Open Live"):
assert invitation.lower() not in words.lower(), (
"the refusal sentence has become an invitation: " + invitation
)


def test_the_card_is_styled() -> None:
assert "#deployment" in _CSS_RULES, "nothing styles the card"
Loading