* Studio: scale menu, toast, chat and composer icons with the UI font size Glyphs that sit beside scaled labels now follow the preference: the shared --icon-size token (nav, settings tabs, chat action bars, code block actions), classed svgs inside dropdown, select, context, menubar, popover and command surfaces, toasts, the chat thread and both composers, and the composer pill glyph slot. Sonner toast text is unpinned from its injected 13px. Hit targets, paddings and surface geometry stay fixed and every value is identity at the default size. * Studio: icons scale at half the UI font size rate; cover review gaps Icons now follow the preference at half the rate of the text, matching the logo lockup: base + (setting - 16) / 2. The menu specific rules that outranked the scoped block (app-user-menu, unsloth-plus-menu, unsloth-tick) carry the scale too, which also restores the plus menu's intended 1.15rem glyph base at the default size. From review: closed select triggers join the scoped surfaces so their chevron tracks the label, sonner action button labels scale at full text rate alongside the title and description, and the unused built-in sonner loader gets a defensive size override. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: icons match the text scale below the default, half rate above Piecewise icon scaling: below the 16px default icons follow the UI font size at the full text rate, above it they move at half the rate so glyphs stay slightly smaller than the text. Written as min(full, half) since the smaller branch is correct on each side. Applies to the shared --icon-size token, the scoped menu, toast, chat and composer overrides, and the menu rules that outrank them. * Studio: cap icons at their default size above the 16px setting Below the default icons still match the text scale; above it they now keep their default size instead of growing at half rate, so enlarged text dominates and glyphs read slightly smaller than the text. The curve is min(full rate, base). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: icons above the default scale at half rate, not capped A 16px glyph at setting 20 renders 18px, as if the setting were 18: above the default icons move at half the rate of the text, below it they match the text scale. The curve is min(full rate, half rate). * Studio: standard icons render at the UI font size itself One shared --ui-icon-size token replaces the per-base curves for every glyph with a 16px or larger base: icons match the UI font size below the default and grow at half the change above it, so setting 12 gives 12px icons, 16 gives 16px and 20 gives 18px, slightly smaller than the enlarged text. Sub 16px glyphs keep their proportions through the same curve as a factor. This also slims the previous 18px to 21px icon bases down to the font size at the default setting. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: icon scale review fixes for ticks, comboboxes and art glyphs From review: thinking ticks keep their own size inside plus menus (the important menu rule now excludes them), combobox popups and triggers join the scoped surfaces, 24px size-6 art glyphs such as attachment tile icons go back to proportional scaling instead of the uniform token, branch picker 36px chevrons scale proportionally beside their counter, and buttons that default un-classed icons to size-4 get the shared token (xs buttons keep their pinned small icons). Sonner cancel labels already scale: sonner renders cancel with data-button set, so the existing override reaches it. * Studio: keep the toast close glyph compact The button icon fallback matched Sonner's close button, whose unclassed 12px X then rendered at the shared icon size inside its fixed control. Exclude data-close-button from the fallback. * Studio: use text-ui-11 for the new chat settings sheet caption The raw px guard caught a text-[11px] added on main; raw px text ignores the UI font size preference. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
240 lines
9 KiB
Python
240 lines
9 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 regression (Settings > Appearance).
|
|
|
|
Drives the real appearance controls and asserts the typography-scale
|
|
contract: text and line heights scale by size/16, the root font size and
|
|
layout geometry never move, an explicit Code font size stays fixed, and an
|
|
overflowing Radix select scrolls its viewport by keyboard and wheel.
|
|
|
|
Runs against an already-booted, already-bootstrapped Unsloth:
|
|
BASE_URL=http://127.0.0.1:18894 STUDIO_PW=... python tests/studio/playwright_ui_font_scale.py
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from _playwright_robust import wait_for_health # noqa: E402
|
|
|
|
BASE = os.environ["BASE_URL"]
|
|
PW = os.environ["STUDIO_PW"]
|
|
ART = Path(os.environ.get("PW_ART_DIR", "logs/playwright_fontscale"))
|
|
ART.mkdir(parents = True, exist_ok = True)
|
|
|
|
SIZES = (12, 20)
|
|
DEFAULT = 16
|
|
|
|
|
|
def step(s):
|
|
print(f"[font-scale] STEP {s}", flush = True)
|
|
|
|
|
|
def fail(m):
|
|
raise AssertionError(f"[font-scale] FAIL: {m}")
|
|
|
|
|
|
def near(
|
|
a,
|
|
b,
|
|
tol = 0.35,
|
|
):
|
|
return a is not None and b is not None and abs(a - b) <= tol
|
|
|
|
|
|
MEASURE_JS = """
|
|
() => {
|
|
const fs = (el) => (el ? parseFloat(getComputedStyle(el).fontSize) : null);
|
|
const lh = (el) => (el ? parseFloat(getComputedStyle(el).lineHeight) : null);
|
|
const byText = (txt) =>
|
|
[...document.querySelectorAll("span, h2, label, p")].find(
|
|
(e) => e.textContent.trim() === txt,
|
|
);
|
|
const nav = byText("New chat");
|
|
const sidebar =
|
|
document.querySelector("[data-slot='sidebar-container']") ??
|
|
document.querySelector("aside") ??
|
|
document.querySelector("nav");
|
|
return {
|
|
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
|
uiAttr: document.documentElement.getAttribute("data-ui-font-size"),
|
|
navFont: fs(nav),
|
|
navLine: lh(nav),
|
|
sidebarW: sidebar ? sidebar.getBoundingClientRect().width : null,
|
|
};
|
|
}
|
|
"""
|
|
|
|
|
|
def measure(page):
|
|
return page.evaluate(MEASURE_JS)
|
|
|
|
|
|
def set_input(page, label, value):
|
|
field = page.locator(f"input[aria-label='{label}']")
|
|
field.scroll_into_view_if_needed()
|
|
field.fill(str(value))
|
|
page.keyboard.press("Enter")
|
|
page.wait_for_timeout(600)
|
|
|
|
|
|
def open_appearance(page):
|
|
page.keyboard.press("Control+,")
|
|
page.wait_for_timeout(700)
|
|
if page.get_by_role("dialog").count() == 0:
|
|
page.keyboard.press("Meta+,")
|
|
page.wait_for_timeout(700)
|
|
if page.get_by_role("dialog").count() == 0:
|
|
fail("settings dialog did not open")
|
|
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click()
|
|
page.wait_for_timeout(600)
|
|
|
|
|
|
def main():
|
|
wait_for_health(BASE)
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch()
|
|
page = browser.new_page(viewport = {"width": 1440, "height": 900})
|
|
page.goto(BASE, wait_until = "networkidle")
|
|
pw_field = page.locator("input[type='password']")
|
|
if pw_field.count():
|
|
pw_field.first.fill(PW)
|
|
page.keyboard.press("Enter")
|
|
page.wait_for_load_state("networkidle")
|
|
page.wait_for_timeout(1500)
|
|
|
|
step("baseline at the default size")
|
|
open_appearance(page)
|
|
set_input(page, "UI font size", DEFAULT)
|
|
base = measure(page)
|
|
if base["root"] != 16:
|
|
fail(f"root font size not 16 at default: {base['root']}")
|
|
if base["navFont"] is None or base["sidebarW"] is None:
|
|
fail(f"baseline samples missing: {base}")
|
|
|
|
for size in SIZES:
|
|
step(f"UI font size {size}")
|
|
set_input(page, "UI font size", size)
|
|
m = measure(page)
|
|
ratio = size / DEFAULT
|
|
if m["root"] != 16:
|
|
fail(f"root font size moved at {size}: {m['root']}")
|
|
if m["uiAttr"] != str(size):
|
|
fail(f"data-ui-font-size wrong at {size}: {m['uiAttr']}")
|
|
if not near(m["navFont"], base["navFont"] * ratio):
|
|
fail(f"nav font at {size}: {base['navFont']} -> {m['navFont']}")
|
|
if not near(m["navLine"], base["navLine"] * ratio):
|
|
fail(f"nav line-height at {size}: {base['navLine']} -> {m['navLine']}")
|
|
if not near(m["sidebarW"], base["sidebarW"], 0.75):
|
|
fail(f"sidebar width moved at {size}: {base['sidebarW']} -> {m['sidebarW']}")
|
|
page.screenshot(path = str(ART / f"scale-{size}.png"))
|
|
|
|
step("explicit Code font size stays fixed under UI 20")
|
|
set_input(page, "Code font size", 13)
|
|
res = page.evaluate(
|
|
"""
|
|
() => {
|
|
const pre = document.createElement("pre");
|
|
pre.textContent = "sample";
|
|
document.body.appendChild(pre);
|
|
const size = getComputedStyle(pre).fontSize;
|
|
pre.remove();
|
|
return size;
|
|
}
|
|
"""
|
|
)
|
|
if res != "13px":
|
|
fail(f"explicit code font size scaled: {res}")
|
|
code_field = page.locator("input[aria-label='Code font size']")
|
|
code_field.fill("")
|
|
page.keyboard.press("Enter")
|
|
page.wait_for_timeout(400)
|
|
|
|
step("overflowing select scrolls its Radix viewport")
|
|
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Voice").first.click()
|
|
page.wait_for_timeout(600)
|
|
page.set_viewport_size({"width": 1440, "height": 480})
|
|
page.locator("[aria-label='Dictation language']").click()
|
|
page.wait_for_timeout(700)
|
|
state = page.evaluate(
|
|
"""
|
|
() => {
|
|
const vp = document.querySelector("[data-radix-select-viewport]");
|
|
return vp
|
|
? { scrollable: vp.scrollHeight > vp.clientHeight, top: vp.scrollTop }
|
|
: null;
|
|
}
|
|
"""
|
|
)
|
|
if not state or not state["scrollable"]:
|
|
fail(f"select viewport not scrollable: {state}")
|
|
for _ in range(6):
|
|
page.keyboard.press("ArrowDown")
|
|
page.wait_for_timeout(100)
|
|
kb_top = page.evaluate(
|
|
"() => document.querySelector('[data-radix-select-viewport]').scrollTop"
|
|
)
|
|
if not kb_top > 0:
|
|
fail(f"keyboard did not scroll the select viewport: {kb_top}")
|
|
vp_box = page.locator("[data-radix-select-viewport]").bounding_box()
|
|
page.mouse.move(vp_box["x"] + vp_box["width"] / 2, vp_box["y"] + 40)
|
|
page.mouse.wheel(0, -400)
|
|
page.wait_for_timeout(300)
|
|
wheel_top = page.evaluate(
|
|
"() => document.querySelector('[data-radix-select-viewport]').scrollTop"
|
|
)
|
|
if not wheel_top < kb_top:
|
|
fail(f"wheel did not scroll the select viewport: {kb_top} -> {wheel_top}")
|
|
page.keyboard.press("Escape")
|
|
page.set_viewport_size({"width": 1440, "height": 900})
|
|
page.wait_for_timeout(400)
|
|
|
|
step("cn keeps text-ui-* next to color classes (hub tabs)")
|
|
page.keyboard.press("Escape")
|
|
page.wait_for_timeout(400)
|
|
page.goto(f"{BASE}/hub", wait_until = "domcontentloaded")
|
|
page.wait_for_timeout(2000)
|
|
open_appearance(page)
|
|
set_input(page, "UI font size", 12)
|
|
page.keyboard.press("Escape")
|
|
page.wait_for_timeout(400)
|
|
tab = page.get_by_role("radio").filter(has_text = "Discover").first
|
|
tab.wait_for(state = "visible", timeout = 15000)
|
|
tab_font = tab.evaluate("el => parseFloat(getComputedStyle(el).fontSize)")
|
|
# text-ui-12p5 at scale 0.75; 16px means twMerge dropped the token.
|
|
if not near(tab_font, 12.5 * 12 / 16):
|
|
fail(f"hub tab font did not scale (twMerge drop?): {tab_font}")
|
|
icon_w = page.evaluate(
|
|
"() => { const el = document.querySelector('.size-icon');"
|
|
" return el ? parseFloat(getComputedStyle(el).width) : null; }"
|
|
)
|
|
# Standard icons render at the UI font size itself below the
|
|
# default, so setting 12 gives 12px glyphs.
|
|
if not near(icon_w, 12):
|
|
fail(f"size-icon did not match the UI font size below 16: {icon_w}")
|
|
page.goto(BASE, wait_until = "domcontentloaded")
|
|
page.wait_for_timeout(1500)
|
|
open_appearance(page)
|
|
|
|
step("default restores exactly")
|
|
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click()
|
|
page.wait_for_timeout(500)
|
|
set_input(page, "UI font size", DEFAULT)
|
|
final = measure(page)
|
|
for key in ("root", "navFont", "navLine", "sidebarW"):
|
|
if not near(final[key], base[key], 0.35):
|
|
fail(f"default drifted for {key}: {base[key]} -> {final[key]}")
|
|
if final["uiAttr"] is not None:
|
|
fail(f"data-ui-font-size present at default: {final['uiAttr']}")
|
|
|
|
page.screenshot(path = str(ART / "restored-default.png"))
|
|
browser.close()
|
|
print("[font-scale] PASS", flush = True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|