diff --git a/static/style.css b/static/style.css index d2116bcbd..283d50f8c 100644 --- a/static/style.css +++ b/static/style.css @@ -9685,11 +9685,11 @@ body.fullwidth-chat .chat-history { .agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after { content: ''; position: absolute; - /* -17px (was -15) nudges the terminating dot 2px further left so it - sits flush with the thread's left rail when the search node is - expanded. This is the "big glow dot at the bottom" the user sees - after a web_search step. */ - left: -17px; + /* Centre the 6px dot on the rail: the rail sits at left:5px + 2px wide, + so its centre is 6px from the thread's left edge and the dot's left + edge belongs at 3px — i.e. 3px - padding-left. Keep this in step with + .agent-thread-dot and with the mobile padding override below. */ + left: -19px; bottom: 5px; width: 6px; height: 6px; @@ -9958,6 +9958,11 @@ body.fullwidth-chat .chat-history { left: -16px; top: 10px; } + /* padding-left drops to 18px here, so the terminating dot needs its own + offset (3px - 18px) to stay centred on the rail. */ + .agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after { + left: -15px; + } } /* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */ diff --git a/tests/test_agent_thread_dot_alignment_css.py b/tests/test_agent_thread_dot_alignment_css.py new file mode 100644 index 000000000..5c3558f7b --- /dev/null +++ b/tests/test_agent_thread_dot_alignment_css.py @@ -0,0 +1,105 @@ +"""Agent-thread timeline dots must stay centred on the vertical rail. + +Source-text assertions here are the narrow exception allowed by +TESTING_STANDARD.md: the invariant is pure CSS geometry, and driving it at +runtime would need a real layout engine, which the suite has no runner for. +The test parses the declared pixel values and recomputes the centres rather +than pinning literals, so retuning the spacing keeps it green — but moving a +breakpoint's padding without moving both dot offsets turns it red. + +The thread's padding is restated once per breakpoint, and each breakpoint's +overrides follow its own `.agent-thread` rule, so a rule's position in the +file identifies the breakpoint it belongs to. +""" + +import re +from pathlib import Path + + +CSS = (Path(__file__).resolve().parents[1] / "static" / "style.css").read_text( + encoding="utf-8" +) + +THREAD = r"^[ \t]*\.agent-thread[ \t]*\{" +RAIL = r"^[ \t]*\.agent-thread::before[ \t]*\{" +STEP_DOT = r"^[ \t]*\.agent-thread-dot[ \t]*\{" +END_DOT = ( + r"^[ \t]*\.agent-thread:not\(\.has-bottom\) " + r"\.agent-thread-node\.open:last-child::after[ \t]*\{" +) + + +def _blocks(selector: str) -> list[tuple[int, str]]: + """(position, declaration block) for each rule using this exact selector.""" + return [ + (m.start(), CSS[m.end() : CSS.index("}", m.end())]) + for m in re.finditer(selector, CSS, re.MULTILINE) + ] + + +def _px(block: str, prop: str) -> float | None: + m = re.search(rf"(?:^|;|/)\s*{prop}\s*:\s*(-?[\d.]+)px", block) + return float(m.group(1)) if m else None + + +def _padding_left(block: str) -> float | None: + explicit = _px(block, "padding-left") + if explicit is not None: + return explicit + shorthand = re.search(r"(?:^|;)\s*padding\s*:([^;]+);", block) + sides = shorthand.group(1).split() if shorthand else [] + return float(sides[3].removesuffix("px")) if len(sides) == 4 else None + + +def _breakpoints() -> list[tuple[float, int, int]]: + """(padding-left, window start, window end) for each declared breakpoint.""" + threads = _blocks(THREAD) + assert threads, "no .agent-thread rule found" + bounds = [pos for pos, _ in threads] + [len(CSS)] + return [ + (_padding_left(block), bounds[i], bounds[i + 1]) + for i, (_, block) in enumerate(threads) + ] + + +def _in_force(selector: str, prop: str, start: int, end: int, fallback): + """Value a breakpoint's window declares, else the one it inherits.""" + declared = [_px(b, prop) for pos, b in _blocks(selector) if start <= pos < end] + values = [v for v in declared if v is not None] + return values[-1] if values else fallback + + +def _centres() -> list[tuple[float, float, float]]: + rail = _blocks(RAIL)[0][1] + rail_centre = _px(rail, "left") + _px(rail, "width") / 2 + + base_step, base_end = _blocks(STEP_DOT)[0][1], _blocks(END_DOT)[0][1] + step_width, end_width = _px(base_step, "width"), _px(base_end, "width") + + rows = [] + for padding, start, end in _breakpoints(): + step_left = _in_force(STEP_DOT, "left", start, end, _px(base_step, "left")) + end_left = _in_force(END_DOT, "left", start, end, _px(base_end, "left")) + rows.append( + ( + rail_centre, + padding + step_left + step_width / 2, + padding + end_left + end_width / 2, + ) + ) + return rows + + +def test_step_dots_are_centred_on_the_rail_at_every_breakpoint(): + for index, (rail, step, _) in enumerate(_centres()): + assert step == rail, ( + f"step dot at breakpoint {index} sits {step - rail}px off the rail" + ) + + +def test_terminating_dot_is_centred_on_the_rail_at_every_breakpoint(): + for index, (rail, _, end) in enumerate(_centres()): + assert end == rail, ( + f"terminating dot at breakpoint {index} sits {end - rail}px " + "off the rail" + )