Studio: scale menu, toast, chat and composer icons with the UI font size (#7400)

* 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>
This commit is contained in:
Michael Han 2026-07-24 14:50:59 -07:00 committed by GitHub
commit 0e3e4f3180
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 151 additions and 12 deletions

View file

@ -966,7 +966,7 @@ export function ChatSettingsPanel({
Delete
</Button>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
<p className="text-ui-11 leading-relaxed text-muted-foreground">
Saving a preset also stores current load settings (context length,
KV cache dtype, speculative decoding, GPU layers).
{currentLoadSummary ? (

View file

@ -282,8 +282,13 @@
/* Standard interactive-icon size for nav, menus, action bars, and
in-message code-block actions. Sized one step above body text so
icons read as minimally larger than adjacent labels (~14px text).
Follows the UI font size preference; 18px at the default.
Theme-independent declared once in :root. */
--icon-size: 18px;
/* Standard icon size follows the UI font size itself: matches it below
the 16px default, grows at half the change above it (setting 20 ->
18px), so icons read slightly smaller than enlarged text. */
--ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));
--icon-size: var(--ui-icon-size);
/* Inset of a centered .size-icon glyph within a 2rem (size-8) action
button i.e. (32px icon-size) / 2. Use as a negative margin on a
chat-message action bar so the leftmost icon's visual edge aligns
@ -1265,8 +1270,8 @@ html[data-chat-font] .aui-root {
}
.app-user-menu [data-slot="dropdown-menu-item"] svg,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] svg {
width: 19px !important;
height: 19px !important;
width: var(--ui-icon-size) !important;
height: var(--ui-icon-size) !important;
flex-shrink: 0;
}
.app-user-menu [data-slot="dropdown-menu-item"]:focus,
@ -1561,20 +1566,20 @@ html[data-chat-font] .aui-root {
/* Fixed-width icon slot so every pill's icon occupies the same space and
the labels line up on an even rhythm, regardless of icon size. */
.composer-pill-glyph {
@apply relative inline-flex w-[19px] shrink-0 items-center justify-center transition-opacity;
@apply relative inline-flex w-[var(--ui-icon-size)] shrink-0 items-center justify-center transition-opacity;
}
/* On hover the icon swaps for an X inside a soft circle (ChatGPT-style),
filling the icon slot so every pill's X is identical and centered. */
.composer-pill-x {
@apply pointer-events-none absolute inset-0 m-auto size-[19px] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
@apply pointer-events-none absolute inset-0 m-auto size-[var(--ui-icon-size)] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
}
/* Icon-only (compact) pills are too small for the circle, so show a bare x. */
[data-pill-compact="true"]
.composer-pill-btn:not([data-keep-label])
.composer-pill-x {
@apply size-[15px] bg-transparent p-0 dark:bg-transparent;
@apply size-[min(calc(15px*var(--ui-font-scale,1)),calc(7.5px+7.5px*var(--ui-font-scale,1)))] bg-transparent p-0 dark:bg-transparent;
}
/* Compact pills hide their labels, so surface the name as a hover
@ -1853,8 +1858,8 @@ html[data-chat-font] .aui-root {
/* Smaller tick for selected Thinking options. */
.unsloth-tick {
width: 0.8rem !important;
height: 0.8rem !important;
width: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important;
height: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important;
}
/* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */
@ -1943,9 +1948,9 @@ html[data-chat-font] .aui-root {
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-sub-trigger"]
)
svg {
width: 1.15rem;
height: 1.15rem;
svg:not(.unsloth-tick) {
width: var(--ui-icon-size) !important;
height: var(--ui-icon-size) !important;
}
/* Destructive items keep red text and a red-tinted hover, not the grey one. */
@ -2770,3 +2775,96 @@ html[data-chat-font] .aui-root {
display: block !important;
width: 8px;
}
/* Icons that sit beside scaled labels follow the UI font size itself:
glyphs at or above a 16px base render at --ui-icon-size (12 -> 12px,
16 -> 16px, 20 -> 18px), so icons track the text below the default and
read slightly smaller than it above. Sub-16px glyphs keep their
proportions through the same curve as a factor. Menu, select and closed select trigger surfaces, popovers, toasts,
the chat thread and both composers. Only glyphs scale; hit targets,
paddings and surface geometry stay fixed. Identity at the default. */
:is(
[data-slot='dropdown-menu-content'],
[data-slot='dropdown-menu-sub-content'],
[data-slot='select-content'],
[data-slot='select-trigger'],
[data-slot='combobox-content'],
[data-slot='combobox-trigger'],
[data-slot='context-menu-content'],
[data-slot='context-menu-sub-content'],
[data-slot='menubar-content'],
[data-slot='popover-content'],
[data-slot='command'],
[data-sonner-toast],
.composer-action-wrapper,
.aui-composer-action-wrapper,
.aui-action-bar-more-content,
.aui-root
) {
& svg.size-2\.5 { width: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); height: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); }
& svg.size-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
& svg.size-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
& svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-4\.5 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-5 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-6 { width: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); height: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); }
& svg.size-\[5px\] { width: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); height: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); }
& svg.size-\[6px\] { width: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); height: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); }
& svg.size-\[10px\] { width: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); height: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); }
& svg.size-\[11px\] { width: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); height: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); }
& svg.size-\[12px\] { width: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); height: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); }
& svg.size-\[13px\] { width: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); height: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); }
& svg.size-\[14px\] { width: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); height: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); }
& svg.size-\[15px\] { width: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); height: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); }
& svg.size-\[15\.5px\] { width: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); height: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); }
& svg.size-\[16px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[17px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[18px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[18\.5px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[20px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[21px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[22px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
& svg.size-\[36px\] { width: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); height: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); }
& svg.w-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
& svg.h-3 { height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
& svg.w-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
& svg.h-3\.5 { height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
& svg.w-4 { width: var(--ui-icon-size); }
& svg.h-4 { height: var(--ui-icon-size); }
& svg.w-5 { width: var(--ui-icon-size); }
& svg.h-5 { height: var(--ui-icon-size); }
/* Buttons default un-classed icons to size-4 the same way. Sonner's
close button keeps its compact 12px glyph inside a fixed control. */
& button:not([class*=':size-3'], [data-close-button]) svg:not([class*='size-'], [class*='w-'], [class*='h-'], .unsloth-tick) {
width: var(--ui-icon-size);
height: var(--ui-icon-size);
}
/* Menu items default un-classed icons to size-4. */
& [data-slot*='item'] svg:not([class*='size-'], [class*='w-'], [class*='h-']) {
width: var(--ui-icon-size);
height: var(--ui-icon-size);
}
}
/* Sonner injects fixed 13px toast text and 12px action labels at runtime;
text follows the preference at full rate. Line heights are unitless so
they track automatically. */
[data-sonner-toast][data-styled='true'] {
font-size: calc(13px * var(--ui-font-scale, 1)) !important;
}
[data-sonner-toast][data-styled='true'] [data-description] {
font-size: calc(13px * var(--ui-font-scale, 1)) !important;
}
[data-sonner-toast][data-styled='true'] [data-button] {
font-size: calc(12px * var(--ui-font-scale, 1)) !important;
}
/* Sonner's icon well is a fixed 16px box; track the glyph. */
[data-sonner-toast][data-styled='true'] [data-icon] {
width: var(--ui-icon-size) !important;
height: var(--ui-icon-size) !important;
}
/* Defensive: the built-in loader is unused (a custom loading icon is always
passed) but keep its fixed --size on the scale in case that changes. */
[data-sonner-toast] .sonner-loading-wrapper {
--size: var(--ui-icon-size) !important;
}

View file

@ -208,6 +208,14 @@ def main():
# 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)

View file

@ -98,6 +98,39 @@ def test_cn_knows_the_ui_typography_tokens():
assert "/^ui-\\d+(p5)?$/.test(value)" in UTILS
def test_icons_follow_the_ui_font_size_itself():
"""Standard glyphs render at --ui-icon-size, which follows the UI font
size itself: matches it below the 16px default and grows at half the
change above it (setting 20 gives 18px icons), so icons track the text
when shrinking and read slightly smaller than it when growing. Sub 16px
glyphs keep their proportions through the same curve as a factor.
Sonner toast text and action labels are text, so they follow at full
rate everywhere."""
assert (
"--ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), "
"calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));"
) in INDEX_CSS
assert "--icon-size: var(--ui-icon-size);" in INDEX_CSS
assert "& svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }" in INDEX_CSS
assert "font-size: calc(13px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS
assert "font-size: calc(12px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS
# Menu rules that outrank the scoped block must carry the token too,
# without flattening the smaller thinking ticks.
assert "width: var(--ui-icon-size) !important;" in INDEX_CSS
assert "svg:not(.unsloth-tick) {" in INDEX_CSS
# Oversized art glyphs stay proportional instead of uniform.
assert "& svg.size-6 { width: min(calc(1.5rem" in INDEX_CSS
for scope in (
"[data-slot='dropdown-menu-content']",
"[data-slot='select-content']",
"[data-slot='select-trigger']",
"[data-slot='combobox-content']",
"[data-sonner-toast]",
".aui-root",
):
assert scope in INDEX_CSS
def test_no_raw_pixel_text_utilities():
offenders = []
for path in _frontend_sources():