Clip the native select's value and size the webkit scrollbar on both axes

WebKit computes `overflow: visible` on a native select, so a long selected
option (the Telegram Command-mode label) painted past the control and became
scrollable overflow of `.settings-scroll`, whose `overflow-y: auto` makes
`overflow-x` compute to `auto`: Settings > Advanced scrolled sideways in the
desktop shell (+147 / +103 / +53 px at 900 / 1000 / 1100 px windows; Blink
clips). `select.ui-control` now clips its value. The global
`::-webkit-scrollbar` recipe sized only `width` (the vertical bar) since the
initial commit, so every horizontal bar kept the 16-17 px UA thickness with
the accent thumb; it now sizes both axes and paints a transparent corner.

DESIGN.md states that a control never widens its column; the Design System
section of DEVELOPMENT.md names the containment rules and the two open gaps
(the wizard document does not load style.css; standard scrollbar-* properties
opt an element out of the webkit recipe on Blink). Two static pins in
tests/test_web_typography_static.py and one WebKit browser test with a
synthetic extension form keep the class closed.
This commit is contained in:
Ouroboros 2026-09-14 17:07:37 +03:00
parent f552e4263e
commit 8bb1e4bea9
6 changed files with 311 additions and 2 deletions

View file

@ -155,6 +155,9 @@ never the field's only name. Help and validation belong to that field without
changing the alignment of neighboring controls and their actions.
Short fixed choices keep native selects, including the platform's own popup.
A control never widens its column: a select shows its chosen label on one
line, clipped at its own edge, and the full label stays in the platform's
popup.
Model selection uses the shared editable chooser: suggestions assist typing
without becoming an allowlist. A saved unknown model remains editable; a
catalog refresh preserves the real input, selection and composition. Escape

View file

@ -3169,6 +3169,26 @@ SPA, not a relocatable-page or multi-instance panel framework.
`.style.<property>` assignments are review debt (a dynamic measured value
may update a narrowly named custom property when that is the real runtime
data flow).
- Containment: a control never widens its column, and horizontal overflow
lives in the wrapper that owns the wide content and declares
`overflow-x: auto` (code block, `.md-table-wrap`, tab strip, Costs table
cells) — never in a page scroll body, whose `overflow-y: auto` alone
already makes `overflow-x` compute to `auto`. The shared
`select.ui-control` recipe therefore clips its own value
(`overflow: hidden`): WebKit computes `overflow: visible` on a native
select, so an unclipped option label becomes scrollable overflow of that
page scroller. A grid track holding controls takes a minimum that yields
to its container — `minmax(0, …)`, or
`repeat(auto-fit, minmax(min(100%, Npx), 1fr))`; a fixed px minimum
rescued only by a viewport media query is review debt, because the
viewport does not know how wide the content column is. The global webkit
scrollbar recipe sizes both axes. Enforced by
`tests/test_web_typography_static.py::test_select_control_clips_its_value`,
its `::test_webkit_scrollbar_recipe_covers_both_axes` neighbour, and
`tests/test_ui_settings_overflow_browser.py`; two gaps stay open — the
wizard document loads `ui.css` without `style.css` and keeps native
scrollbars, and an element setting the standard
`scrollbar-width`/`scrollbar-color` opts out of the webkit recipe on Blink.
- One semantic button variant expresses one action role: neutral Settings
and onboarding controls use the existing `.btn.btn-default`; a one-action
result row uses the named `.settings-action-row` contract (status first,
@ -3260,7 +3280,11 @@ stealing usable text space; use the shared responsive component before
adding a page-specific layout. A visible change is inspected with vision in
at least one relevant real consumer flow. A stored screenshot alone is not
verification; mobile or WebKit is not a universal requirement and is
selected from risk. Review-only: scored by CHECKLISTS items 2(i) and 30
selected from risk. Containment is the WebKit-sensitive exception — a native
select is not clipped there — so a change to a control recipe or a page
scroll body is verified on Playwright WebKit as well as Chromium, measuring
overflow on the scroll body's `scrollWidth` rather than on
`documentElement`. Review-only: scored by CHECKLISTS items 2(i) and 30
(`web_design_system`).
### Browser dialogs

View file

@ -0,0 +1,214 @@
"""WebKit guard: Settings never grows a horizontal scrollbar.
The owner's report was a thick red horizontal scrollbar across the bottom of
Settings -> Advanced in the packaged desktop shell (WKWebView). Blink's UA
stylesheet clips a native ``<select>`` (``overflow: clip``); WebKit computes
``visible``, so a long selected option paints past the control's own box, the
``.settings-scroll`` page scroller (``overflow-x`` resolves to ``auto``) grows,
and the scrollbar the app paints for it was UA-thick because the global
``::-webkit-scrollbar`` recipe sized only ``width``.
Chromium is deliberately NOT exercised here: the class cannot appear there, so
a second engine would only pin the engine that never had the bug.
"""
from __future__ import annotations
import os
import pathlib
import textwrap
import pytest
pytest_plugins = ("tests.test_ui_smoke_playwright",)
# The real trigger from the owner's screenshot: a 65-character sentence as the
# selected option of an extension settings select.
LONG_OPTION_LABEL = "Full access (default) — raw owner commands incl. /panic, /restart"
SECTION_TITLE = "Owner command access"
def _write_long_option_settings_extension(data_dir: pathlib.Path) -> str:
"""Install an exact-hash reviewed extension whose settings form carries the
long-label select, rendered by the production settings renderer.
The field ORDER mirrors the real Telegram section and is load-bearing: the
long select is the SECOND field, so it lands in the right-hand column of
``.form-grid.two``. A leak out of a left-column control is absorbed by the
empty right half of the grid and never reaches the page scroller, so a
left-column reproduction would be green on the unfixed stylesheet too.
"""
from ouroboros.skill_loader import (
SkillReviewState,
compute_content_hash,
save_review_state,
)
name = "settings_overflow_smoke"
skill_dir = data_dir / "skills" / "external" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
textwrap.dedent(
f"""\
---
name: {name}
description: Settings select whose label is long enough to overflow.
version: 0.1.0
type: extension
entry: plugin.py
permissions: ["route", "widget"]
---
# Settings overflow smoke
"""
),
encoding="utf-8",
)
plugin = textwrap.dedent(
'''\
async def save(request):
body = await request.json()
return {"message": f"Saved {body.get('access') or 'default'} access."}
def register(api):
api.register_route("save", save, methods=("POST",))
api.register_settings_section(
"config",
"__SECTION_TITLE__",
schema={
"components": [
{
"type": "form",
"id": "access-form",
"route": "save",
"method": "POST",
"submit_label": "Save access",
"fields": [
{
"name": "language",
"label": "Language",
"type": "select",
"default": "en",
"options": [
{"label": "English", "value": "en"},
{"label": "Russian", "value": "ru"},
],
},
{
"name": "command_mode",
"label": "Command mode",
"type": "select",
"default": "full",
"options": [
{"label": "__LONG_LABEL__", "value": "full"},
{"label": "Strict", "value": "strict"},
],
},
],
}
]
},
)
'''
)
plugin = plugin.replace("__LONG_LABEL__", LONG_OPTION_LABEL)
plugin = plugin.replace("__SECTION_TITLE__", SECTION_TITLE)
(skill_dir / "plugin.py").write_text(plugin, encoding="utf-8")
content_hash = compute_content_hash(skill_dir, manifest_entry="plugin.py")
save_review_state(
data_dir,
name,
SkillReviewState(status="pass", content_hash=content_hash),
)
return name
@pytest.mark.serial
@pytest.mark.ui_browser
def test_settings_advanced_never_scrolls_horizontally_in_webkit(direct_server_with_data):
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
data_dir = direct_server_with_data["data_dir"]
skill = _write_long_option_settings_extension(data_dir)
evidence_dir = pathlib.Path(
os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(data_dir.parent))
)
evidence_dir.mkdir(parents=True, exist_ok=True)
screenshot = evidence_dir / "settings-advanced-webkit-no-horizontal-overflow.png"
try:
with sync_playwright() as pw:
browser = pw.webkit.launch(headless=True)
# The narrow desktop shell window the owner reported from; the leak
# grows as the window narrows, so this is the honest width to pin.
page = browser.new_page(viewport={"width": 1000, "height": 680})
try:
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
toggled = page.evaluate(
"""async (skill) => {
const response = await fetch(`/api/skills/${encodeURIComponent(skill)}/toggle`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: true}),
});
return {status: response.status, body: await response.json()};
}""",
skill,
)
assert toggled["status"] == 200, toggled
assert toggled["body"].get("enabled") is True, toggled
page.wait_for_selector('[data-nav-page="settings"]', timeout=30_000)
page.click('[data-nav-page="settings"]')
page.locator('[data-settings-tab="advanced"]').click()
section = page.locator('.settings-extension-section').filter(
has_text=SECTION_TITLE
)
section.wait_for(state="visible", timeout=30_000)
select = section.locator('select[name="command_mode"]')
select.wait_for(state="visible", timeout=30_000)
# The long option must actually be the selected one, or the
# measurement below would prove nothing.
assert select.input_value() == "full"
assert LONG_OPTION_LABEL in select.inner_text()
overflow = page.evaluate(
"""() => {
const s = document.querySelector('.settings-scroll');
return s.scrollWidth - s.clientWidth;
}"""
)
widths = select.evaluate(
"""(element) => [
element.getBoundingClientRect().width,
element.parentElement.getBoundingClientRect().width,
]"""
)
# `.settings-scroll` is an inner scroller, so a full-page shot
# would frame the top of Advanced and not the offending control.
select.scroll_into_view_if_needed()
page.wait_for_timeout(150)
# Captured before the assertions so a red run leaves evidence too.
page.screenshot(path=str(screenshot), full_page=True)
assert overflow <= 1, (
"Settings scrolls horizontally: .settings-scroll is "
f"{overflow}px wider than its own box. A native select's "
"value is not clipped by WebKit, so a long option leaks "
"into the page scroller and the app paints the horizontal "
"bar the owner reported."
)
assert widths[0] <= widths[1] + 1, (
"the select is wider than its grid cell: "
f"{widths[0]}px control in a {widths[1]}px cell"
)
finally:
browser.close()
except PlaywrightError as exc:
if "Executable doesn't exist" in str(exc) or "playwright install" in str(exc).lower():
pytest.skip(str(exc))
raise

View file

@ -452,3 +452,63 @@ def test_every_focus_visible_selector_gets_the_canonical_ring() -> None:
"focus there is either invisible or a second colour vocabulary "
"(docs/DESIGN.md 'Focus'):\n" + "\n".join(f" {s}" for s in unringed)
)
# ---------------------------------------------------------------------------
# Horizontal overflow: the red scrollbar on Settings -> Advanced
# ---------------------------------------------------------------------------
def test_select_control_clips_its_value() -> None:
"""Pins the owner-visible defect "Settings -> Advanced paints a horizontal
scrollbar in the desktop app": WebKit computes `overflow: visible` on a
native select (Blink's UA sheet clips it), so a long selected option leaks
out of the control and into the page scroller."""
css = _decommented(_read("web/ui.css"))
bodies = [
body for selector, body in RULE.findall(css)
if selector.strip() == "select.ui-control"
]
assert bodies, "no `select.ui-control` rule in web/ui.css"
values = [
part.split(":", 1)[1].strip().lower()
for body in bodies
for part in body.split(";")
if part.strip() and part.split(":", 1)[0].strip() == "overflow"
]
assert values, (
"`select.ui-control` declares no `overflow`, so WebKit lets a long "
"selected option paint past the control's own box and widen the "
"settings scroller"
)
assert all(value != "visible" for value in values), (
f"`select.ui-control` re-opens the clip: overflow {values}"
)
def test_webkit_scrollbar_recipe_covers_both_axes() -> None:
"""Pins the owner-visible defect "the scrollbar is thick and red": the
global `::-webkit-scrollbar` recipe sized only `width`, which is the
VERTICAL bar, so any horizontal bar kept the 16-17px UA thickness while
still wearing the accent thumb."""
css = _decommented(_read("web/style.css"))
bodies = [
body for selector, body in RULE.findall(css)
if selector.strip() == "::-webkit-scrollbar"
]
assert bodies, "no global `::-webkit-scrollbar` rule in web/style.css"
declarations: dict[str, str] = {}
for body in bodies:
for part in body.split(";"):
if ":" not in part:
continue
name, _, value = part.partition(":")
declarations[name.strip().lower()] = value.strip().lower()
assert "width" in declarations and "height" in declarations, (
"the global scrollbar recipe must size both axes; it declares "
f"{sorted(declarations)}"
)
assert declarations["width"] == declarations["height"], (
"the horizontal bar must be as thin as the vertical one: "
f"width {declarations['width']} vs height {declarations['height']}"
)

View file

@ -3503,8 +3503,11 @@ textarea.chat-input::placeholder { color: var(--text-muted); }
gap: 6px;
}
::-webkit-scrollbar { width: 6px; }
/* `width` is the vertical bar, `height` the horizontal one: without the height
a horizontal bar falls back to the 16-17px UA default and paints thick. */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-corner { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--accent-18); border-radius: var(--radius-xs); }
::-webkit-scrollbar-thumb:hover { background: var(--accent-35); }

View file

@ -377,9 +377,14 @@
box-shadow: 0 0 0 3px var(--focus-accent-ring);
}
.ui-control:disabled { color: var(--text-disabled); cursor: not-allowed; }
/* WebKit (the packaged desktop shell) computes `overflow: visible` on a native
select, so a long selected option paints past the control and turns the page
scroller into a horizontal one; the clip keeps it inside the control's box. */
select.ui-control {
-webkit-appearance: none;
appearance: none;
overflow: hidden;
text-overflow: ellipsis;
padding-inline-end: calc(var(--space-3) * 3);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cpath d='M3 5l4 4 4-4' fill='none' stroke='%23e2e8f0' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;