unsloth/tests/studio/test_ui_font_scale_contract.py
Michael Han 8aaf2f78eb
Studio: drive UI font size through a typography scale instead of the root font size (#7359)
* Studio: drive UI font size through a typography scale, not the root font size

Follow up to #7355. The preference now writes --ui-font-scale
(selected / 16) and a data-ui-font-size attribute on the root instead
of mutating the root font size, and the applier clears any stale inline
root font-size left by older builds. Because the rem base never moves,
every layout-only rem-to-px conversion from #7355 is reverted to its
original form: the spacing, radius and container tokens, sidebar and
thread widths, grid tracks, calc margins and hub.css dimensions match
pre-#7355 main again, which also restores rem-based accessibility
scaling for users with a larger browser default font size.

Typography scales through tokens in index.css, all exact at 16px:

- The named Tailwind sizes (--text-xs through --text-4xl) multiply
  their defaults by the scale, so standard utilities scale
- One token per design px size (--text-ui-8 ... --text-ui-34) replaces
  every arbitrary text-[Npx] class; leading-ui-* mirrors the exact
  line heights and the numeric --leading-3..10 scale as well
- CSS font-size and line-height declarations multiply by the scale
- Chart labels scale through a .recharts-text rule; streamdown and
  react-flow px text is re-based via scaled overrides; KaTeX's 1px
  layout trick stays fixed by design
- The logo lockups keep their half-rate behavior via the scale var
- The explicit Code font size remains unmultiplied

Keeps the #7355 behavior fixes: color chip min width, voice select
min/max widths, and the select and dropdown menus scrolling an inner
viewport so their corners stay rounded. The whitespace-password and
IME rename guards that merged alongside are preserved.

* Studio: contract and Playwright coverage for the UI font size scale

test_ui_font_scale_contract.py pins the mechanism (scale var written,
root font size never mutated, tokens scaled, code font size not
multiplied, the Radix select viewport owning scroll state) and guards
against new raw pixel typography, with a documented allowlist for the
recharts fontSize props covered by the stylesheet override and the
offscreen clipboard textarea.

playwright_ui_font_scale.py drives the real appearance controls: root
font size fixed at 12/16/20, text and line height scale by size/16,
sidebar width invariant, explicit code font size stays fixed, an
overflowing dictation select scrolls its Radix viewport by keyboard
and wheel, and the default restores exactly. Wired into the UI smoke
workflow against the second studio boot.

The thinking-compact and descender contracts move back to the rem and
token forms now that layout values no longer need px pinning.
2026-07-23 01:26:56 -07:00

147 lines
5.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""UI font size scaling contracts (Settings > Appearance).
The preference must scale typography through the --ui-font-scale tokens,
never by mutating the root font size, so rem-based layout stays put. These
contracts also act as the guard against reintroducing raw pixel typography
that would silently ignore the preference.
"""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SRC = REPO / "studio/frontend/src"
INDEX_CSS = (SRC / "index.css").read_text(encoding = "utf-8")
STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text(
encoding = "utf-8"
)
SELECT = (SRC / "components/ui/select.tsx").read_text(encoding = "utf-8")
# Raw numeric fontSize props are only allowed where a scaled stylesheet rule
# (.recharts-text) overrides the presentation attribute at render time.
FONTSIZE_PROP_ALLOWED_DIRS = (
"features/studio/sections/charts",
"features/studio/sections/training-section.tsx",
)
# Non-visible typography that intentionally stays fixed.
FONTSIZE_STYLE_ALLOWLIST = {
# Offscreen textarea; 12pt+ suppresses the iOS focus zoom. Never rendered.
"lib/copy-to-clipboard.ts",
}
def _frontend_sources():
for path in sorted(SRC.rglob("*")):
if path.suffix in {".ts", ".tsx", ".css"}:
yield path
def test_preference_writes_a_scale_not_the_root_font_size():
assert 'setVar("--ui-font-scale"' in STORE
assert 'el.setAttribute("data-ui-font-size"' in STORE
# Older builds set an inline root font-size; the applier must clear it.
assert 'style.removeProperty("font-size")' in STORE
assert "style.fontSize" not in STORE
def test_named_text_tokens_scale():
for token, rem in (
("--text-xs", "0.75rem"),
("--text-sm", "0.875rem"),
("--text-base", "1rem"),
("--text-lg", "1.125rem"),
):
assert f"{token}: calc({rem} * var(--ui-font-scale, 1));" in INDEX_CSS
def test_numeric_leading_scales_with_the_preference():
for n, rem in ((3, "0.75rem"), (5, "1.25rem"), (6, "1.5rem")):
assert f"--leading-{n}: calc({rem} * var(--ui-font-scale, 1));" in INDEX_CSS
def test_ui_token_families_exist():
assert "--text-ui-11: calc(0.6875rem * var(--ui-font-scale, 1));" in INDEX_CSS
assert "--text-ui-10p5: calc(0.65625rem * var(--ui-font-scale, 1));" in INDEX_CSS
assert "--leading-ui-17: calc(1.0625rem * var(--ui-font-scale, 1));" in INDEX_CSS
def test_explicit_code_font_size_is_never_multiplied():
match = re.search(
r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS
)
assert match is not None
body = match.group(1)
assert "var(--custom-code-font-size)" in body
assert "--ui-font-scale" not in body
def test_radix_select_viewport_owns_the_scroll_state():
viewport = SELECT[SELECT.index("SelectPrimitive.Viewport") :]
assert "overflow-y-auto" in viewport.split("</SelectPrimitive.Viewport>")[0]
# The rounded surface itself must not scroll (WebKit squares its corners).
content_cls = re.search(r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT)
assert content_cls is not None
assert "overflow-hidden" in content_cls.group(1)
assert "overflow-y-auto" not in content_cls.group(1)
def test_no_raw_pixel_text_utilities():
offenders = []
for path in _frontend_sources():
text = path.read_text(encoding = "utf-8")
for m in re.finditer(r"(?<![\w-])(?:text|leading)-\[[0-9.]+px\]", text):
offenders.append(f"{path.relative_to(SRC)}: {m.group(0)}")
assert offenders == [], (
"Raw px text utilities ignore the UI font size preference; use the "
f"text-ui-* / leading-ui-* tokens in index.css instead: {offenders[:10]}"
)
def test_css_font_sizes_reference_the_scale():
offenders = []
for path in _frontend_sources():
if path.suffix != ".css":
continue
text = path.read_text(encoding = "utf-8")
for m in re.finditer(r"(font-size|line-height):[^;{}]*;", text):
decl = m.group(0)
if re.search(r"[0-9.]+(px|rem)", decl) is None:
continue # unitless ratios and vars scale naturally
if "--ui-font-scale" in decl:
continue
if "1px" in decl:
continue # library layout tricks (KaTeX-style), not text
offenders.append(f"{path.relative_to(SRC)}: {decl.strip()[:80]}")
assert offenders == [], (
"CSS typography must multiply by var(--ui-font-scale, 1) or be "
f"allowlisted here with a reason: {offenders[:10]}"
)
def test_inline_font_size_styles_reference_the_scale():
offenders = []
for path in _frontend_sources():
rel = str(path.relative_to(SRC))
if rel in FONTSIZE_STYLE_ALLOWLIST:
continue
text = path.read_text(encoding = "utf-8")
for m in re.finditer(r"fontSize:\s*([\"'][^\"']+[\"']|[0-9.]+)", text):
value = m.group(1)
if "--ui-font-scale" in value:
continue
if value.replace(".", "").isdigit() and any(
rel.startswith(d) for d in FONTSIZE_PROP_ALLOWED_DIRS
):
continue # covered by the .recharts-text override
offenders.append(f"{rel}: fontSize {value}")
for m in re.finditer(r"fontSize=\{?([0-9.]+)\}?", text):
if not any(rel.startswith(d) for d in FONTSIZE_PROP_ALLOWED_DIRS):
offenders.append(f"{rel}: fontSize={m.group(1)}")
assert offenders == [], (
"Inline font sizes must scale with var(--ui-font-scale, 1) or be "
f"documented in the allowlist: {offenders[:10]}"
)