mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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:
parent
f552e4263e
commit
8bb1e4bea9
6 changed files with 311 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
214
tests/test_ui_settings_overflow_browser.py
Normal file
214
tests/test_ui_settings_overflow_browser.py
Normal 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
|
||||
|
|
@ -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']}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue