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
15 changes: 11 additions & 4 deletions skills/leaf/packages/default/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -671,10 +671,17 @@ lf-options > .lf-done[aria-pressed="true"] { color: var(--ok-ink);
lf-options[choose]:not(:where([data-lf-exhibit]) *) :is(:focus, .lf-focus)
) { outline: none; }
lf-options[choose]:not([settled]):not(:where([data-lf-exhibit]) *)
> :is(
lf-option:has(> .lf-pick:is(:focus-visible, .lf-focus-visible)),
.lf-another:has(input:is(:focus-visible, .lf-focus-visible))
) {
> lf-option:has(> .lf-pick:is(:focus-visible, .lf-focus-visible)) {
outline: var(--here-ring); --lf-here-ring: options-row;
outline-offset: calc(-1 * var(--here-ring-w));
}
/* The add field's own outline is off (below), so the row it sits in is the only thing
* that can answer the keyboard for it — in every group that shows the field, not just
* the open ones. A settled group opened by its disclosure shows the same cell, and
* scoped to :not([settled]) this rule left a reader who had Tabbed into it with
* nothing on screen saying where they were. */
lf-options[choose]:not(:where([data-lf-exhibit]) *)
> .lf-another:has(input:is(:focus-visible, .lf-focus-visible)) {
outline: var(--here-ring); --lf-here-ring: options-row;
outline-offset: calc(-1 * var(--here-ring-w));
}
Expand Down
30 changes: 26 additions & 4 deletions tests/render_cases_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,25 @@ def panel_comment(d, text, anchor=None, author="user"):
)


# The other place a question lives: a widget that seats its own conversation
# (`x-conversation`), where the answer is words rather than a pick. The durable-draft
# tests stand on one because a seat is where a single draft has two views at once — the
# cell in the page and the row in the panel — which is the whole of what those tests
# compare. `jobs` names the seat, so the `say:jobs` draft key and a comment anchored to
# `{section: "jobs"}` are the one coordinate the panel and the page share.
SEATED_QUESTION_PAGE = leaf_page(
"seated question",
"""
<h1 id="h">Three jobs</h1>
<lf-command id="hub" label="Before the frost">
<lf-task id="jobs" status="active" talk><strong>Which jobs are worth starting?</strong>
The mounts came down in January, the bird bath froze eleven mornings, and the camera
is still in its box.</lf-task>
</lf-command>
""",
)


def sent_events(page_dir):
return [
json.loads(line)
Expand Down Expand Up @@ -1075,9 +1094,12 @@ def drifting_widget(tmp_path, monkeypatch, deep=False, bare=False):


# A suggestion whose losing slot holds a widget. lf-old takes prose, and prose takes
# widgets, so the mark on a chosen option can sit inside the half a decision removes.
# `choose`, because that is the shape that bites: a group offering a pick renders the
# mark as a press, which wears the chrome class *and* declares its word the page's.
# widgets, so a generated label can sit inside the half a decision removes.
# `settled`, because that is the shape that bites: the summary a retired group collapses
# to is written by the module and declares its words the page's (`says: true`), which is
# what lets a quote land on them. A pick mark is not that shape — it names the option for
# a listening reader and says nothing the page speaks — so a group offering a live pick
# would leave this case unexercised.
RETIRED_WIDGET_PAGE = leaf_page(
"retired",
"""
Expand All @@ -1086,7 +1108,7 @@ def drifting_widget(tmp_path, monkeypatch, deep=False, bare=False):
<lf-suggestion id="sug-swap">
<lf-old id="was">
<lf-decision id="old-group-decision"><h2>How should sessions travel?</h2>
<lf-options id="old-group" choose>
<lf-options id="old-group" choose settled>
<lf-option id="old-lax" chosen><strong>Lax cookie</strong> The way it stands.</lf-option>
</lf-options></lf-decision>
</lf-old>
Expand Down
17 changes: 13 additions & 4 deletions tests/render_cases_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -1700,11 +1700,20 @@ def in_threads_scrollport(page, selector):
const runX = [Math.max(ring.left, 0), Math.min(ring.right, innerWidth)];
const runY = [Math.max(ring.top, 0), Math.min(ring.bottom, innerHeight)];
const shownRun = runX[0] <= runX[1] && runY[0] <= runY[1];
// Each run sampled in the middle of the band it is, rather than half a pixel inside
// its outer edge. Both points are on the ring; the outer one is also the last
// fraction of a pixel of the control, and hit testing rounds a subpixel edge to the
// device pixel it shares with the next box. Butted cells are where that shows: an
// options group's rows meet on a fractional line, so the ring on the row the
// keyboard is on reported the row below as painting over its bottom edge — the
// seam's rounding, not anything drawn there. Floored at half a pixel so a hairline
// ring still samples inside itself.
const into = Math.max(w / 2, 0.5);
for (const [side, x, y] of ordered && shownRun ? [
['top', mid(...runX), ring.top + 0.5],
['bottom', mid(...runX), ring.bottom - 0.5],
['left', ring.left + 0.5, mid(...runY)],
['right', ring.right - 0.5, mid(...runY)],
['top', mid(...runX), ring.top + into],
['bottom', mid(...runX), ring.bottom - into],
['left', ring.left + into, mid(...runY)],
['right', ring.right - into, mid(...runY)],
] : []) {{
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) continue;
for (const over of document.elementsFromPoint(x, y)) {{
Expand Down
12 changes: 5 additions & 7 deletions tests/render_cases_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,15 @@
# group would sit one pixel apart from the rest while the page shows them level.
#
# The gutter the chip stands in comes back with it, because where the chip belongs is a
# relation to the two boxes either side of it rather than a number. The status rule is the
# option's own `::before` and the prose opens at the column the option pads to, so
# `afterStatus` and `opens` are read where the theme spends them. Written as the number
# they came to, the reading would have to be re-pinned every time either neighbour moved,
# and a re-pinned number proves only that somebody ran the test.
# relation to the boxes either side of it rather than a number. The cell's own start is
# one side and the prose opens at the column the option pads to, so `opens` is read where
# the theme spends it. Written as the number it came to, the reading would have to be
# re-pinned every time either neighbour moved, and a re-pinned number proves only that
# somebody ran the test.
INSIDE_ITS_OPTION = """el => {
const chip = el.getBoundingClientRect();
const opt = el.parentElement.getBoundingClientRect();
const s = getComputedStyle(el.parentElement);
const status = getComputedStyle(el.parentElement, '::before');
const top = opt.y + parseFloat(s.borderTopWidth);
const left = opt.x + parseFloat(s.borderLeftWidth);
const bottom = opt.bottom - parseFloat(s.borderBottomWidth);
Expand All @@ -130,7 +129,6 @@
return {x: chip.x - left, ends: chip.right - left,
y: chip.y - top, past: chip.bottom - bottom,
level: (chip.y + chip.height / 2) - words,
afterStatus: parseFloat(status.left) + parseFloat(status.width),
opens: parseFloat(s.paddingInlineStart)};
}"""

Expand Down
2 changes: 2 additions & 0 deletions tests/render_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
ROOMS,
ROSTER_PAGE,
SCROLL_SETTLED,
SEATED_QUESTION_PAGE,
SETTLED_DECISION_PAGE,
SHORT_SUGGESTION,
SPECIMEN_EXAMPLES,
Expand Down Expand Up @@ -505,6 +506,7 @@
"SCROLL_SETTLED",
"SCROLL_SETTLE_MS",
"SCROLL_STILL",
"SEATED_QUESTION_PAGE",
"SENTENCE",
"SETTLED_DECISION_PAGE",
"SETTLED_PAGE",
Expand Down
42 changes: 25 additions & 17 deletions tests/test_render_drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
KEYS_PAGE,
LONG_PAGE,
NOTED_PAGE,
SEATED_QUESTION_PAGE,
SENTENCE,
SMOOTH_LONG_PAGE,
STORED_DRAFT_SETTLED,
Expand Down Expand Up @@ -981,7 +982,7 @@ def test_a_stale_question_first_message_cannot_append_across_tabs(
proves that readable absence is settlement rather than permission to trust the
old in-memory value.
"""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
first, first_errors = open_page(browser, url, context=one_reader)
second, second_errors = open_page(
browser,
Expand Down Expand Up @@ -1034,7 +1035,7 @@ def test_a_stale_question_first_message_cannot_append_across_tabs(

def test_a_question_reply_appends_one_event_across_tabs(browser, serve, one_reader):
"""Both inline views may POST the shared reply; its attempt appends it once."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
root = events_model.append_event(
serve.page_dir,
{
Expand Down Expand Up @@ -1082,7 +1083,7 @@ def test_a_held_conversation_send_cannot_clear_a_newer_raw_draft(
browser, serve, one_reader
):
"""Settlement compares raw words, so an older POST cannot erase a later edit."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
root = events_model.append_event(
serve.page_dir,
{
Expand Down Expand Up @@ -1139,7 +1140,7 @@ def test_a_failed_concurrent_question_send_keeps_the_accepted_attempt(
"""One request may lose its answer while another tab gets the same attempt
accepted. The first tab adopts that durable outcome instead of reporting failure
or offering the words as a second message."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
first, first_errors = open_page(browser, url, context=one_reader)
second, second_errors = open_page(browser, url, context=one_reader)
first_say = first.locator("#jobs > .lf-conversation > .lf-say")
Expand All @@ -1163,8 +1164,11 @@ def test_a_failed_concurrent_question_send_keeps_the_accepted_attempt(
event for event in sent_events(serve.page_dir) if event["kind"] == "comment"
]
assert [event["text"] for event in roots] == [raw.strip()]
expect(first_say).to_be_hidden()
expect(second_say).to_be_hidden()
# Asked of the words rather than of the box: a seat that can hold keeps its composer
# standing after every root (renderConversations), so an empty one is what says the
# tab adopted the durable outcome instead of holding the words for a second send.
expect(first_say.locator("textarea")).to_have_value("")
expect(second_say.locator("textarea")).to_have_value("")
assert first_errors == []
assert second_errors == []

Expand All @@ -1173,7 +1177,7 @@ def test_a_question_can_send_when_draft_storage_refuses_writes(browser, serve):
"""Persistence failure costs recovery, not the live textarea's Send action."""
page, errors = open_page(
browser,
serve(DECISION_PAGE),
serve(SEATED_QUESTION_PAGE),
init_script="""Storage.prototype.setItem = function () {
throw new DOMException('blocked', 'SecurityError');
};""",
Expand Down Expand Up @@ -1213,7 +1217,7 @@ def test_a_closed_sender_cannot_append_its_accepted_attempt_twice(
replacement must not learn the attempt is in the log before it sends, or it would
correctly decline to. Both go through `held_stale` rather than a live `page.route`,
which reaches no poll already in the wire."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
first, _ = open_page(browser, url, context=held_stale(one_reader))
second_held = held_stale(one_reader)
second, second_errors = open_page(browser, url, context=second_held)
Expand Down Expand Up @@ -1257,7 +1261,7 @@ def test_an_older_settlement_cannot_erase_a_newer_failed_write(
browser, serve, one_reader
):
"""A nondurable local generation outranks storage news about its predecessor."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
other, other_errors = open_page(browser, url, context=one_reader)
old = "The older persisted answer."
other_say = other.locator("#jobs > .lf-conversation > .lf-say")
Expand Down Expand Up @@ -1299,7 +1303,7 @@ def test_an_accepted_nondurable_branch_cannot_tombstone_a_newer_shared_generatio
browser, serve, one_reader
):
"""A held older send reconciles its base before writing settlement."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
older, older_errors = open_page(
browser,
url,
Expand Down Expand Up @@ -1352,7 +1356,7 @@ def test_a_nondurable_branch_yields_to_unrelated_live_storage_news(
browser, serve, one_reader
):
"""Only news from a branch's base may be replaced by that local branch."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
local, local_errors = open_page(
browser,
url,
Expand Down Expand Up @@ -1393,7 +1397,7 @@ def test_a_delayed_storage_event_cannot_send_a_stale_durable_generation(
browser, serve, one_reader
):
"""Send refreshes shared storage instead of trusting a stale durable cache."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
stale, stale_errors = open_page(
browser,
url,
Expand Down Expand Up @@ -1464,7 +1468,7 @@ def test_poll_settlement_cannot_tombstone_a_newer_durable_generation(
browser, serve, one_reader
):
"""Log reconciliation settles only the generation still shared by storage."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
# The hold costs the most here, where the poll released at the end is the subject
# rather than an interruption: an earlier poll reconciling this tab onto the newer
# generation leaves settlement nothing older to be tempted by, so the assertions
Expand Down Expand Up @@ -1521,7 +1525,7 @@ def test_a_read_failure_cannot_make_a_successfully_written_draft_unsendable(
"""The document cache owns its generation even when getItem later refuses it."""
page, errors = open_page(
browser,
serve(DECISION_PAGE),
serve(SEATED_QUESTION_PAGE),
init_script="""Storage.prototype.getItem = function () {
throw new DOMException('blocked', 'SecurityError');
};""",
Expand All @@ -1544,7 +1548,7 @@ def test_a_remove_failure_cannot_resurrect_an_accepted_draft(
browser, serve, one_reader
):
"""Settlement is a record and a log fact; draft cleanup never calls removeItem."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
first, first_errors = open_page(
browser,
url,
Expand All @@ -1561,7 +1565,11 @@ def test_a_remove_failure_cannot_resurrect_an_accepted_draft(
first.wait_for_function(STORED_DRAFT_SETTLED, arg="say:jobs")

again, again_errors = open_page(browser, url, context=one_reader)
expect(again.locator("#jobs > .lf-conversation > .lf-say")).to_have_count(0)
# The composer is still standing — a seat that can hold keeps it — so the claim is
# about what it opens with: a settled draft is words the next tab must not be handed.
expect(again.locator("#jobs > .lf-conversation > .lf-say textarea")).to_have_value(
""
)
roots = [
event for event in sent_events(serve.page_dir) if event["kind"] == "comment"
]
Expand All @@ -1572,7 +1580,7 @@ def test_a_remove_failure_cannot_resurrect_an_accepted_draft(

def test_an_intentional_later_identical_reply_gets_a_fresh_attempt(browser, serve):
"""Identity follows the edit generation, never content or a time window."""
url = serve(DECISION_PAGE)
url = serve(SEATED_QUESTION_PAGE)
root = events_model.append_event(
serve.page_dir,
{
Expand Down
Loading
Loading