Studio: register text-ui tokens with tailwind-merge so cn() keeps them (#7396)

* Studio: register text-ui tokens with tailwind-merge so cn keeps them

Stock tailwind-merge classifies text-ui-* as a text color, so cn() dropped
the size class whenever a color utility followed it in the same call. The
element then fell back to the unscaled 16px root font, which made hub tabs
and capability pills look oversized at small UI font sizes. Extend the
merge config so text-ui-* and leading-ui-* resolve as font-size and
line-height groups, and cover the failure in the contract and Playwright
regression tests.

* Studio: rename the Models page to Model hub

Page heading, sidebar navigation label in all locales, and the chat
download toasts that point at the tab.
This commit is contained in:
Michael Han 2026-07-24 00:48:54 -07:00 committed by GitHub
commit 140b3fbe05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 59 additions and 15 deletions

View file

@ -2289,7 +2289,7 @@ export function ChatPage({
} else if (outcome === "conflict") {
toast.info("Resume this download from Models", {
description:
"An earlier partial download used a different transport. Open the Models tab to resume or restart it.",
"An earlier partial download used a different transport. Open the Model hub tab to resume or restart it.",
});
} else if (outcome === "busy") {
toast.info("Download already in progress", {
@ -2410,7 +2410,7 @@ export function ChatPage({
// surface's onComplete auto-loads, mirroring the "started" branch.
toast.info("Resume this download from Models", {
description:
"An earlier partial download used a different transport. Open the Models tab to resume or restart it.",
"An earlier partial download used a different transport. Open the Model hub tab to resume or restart it.",
});
return;
}

View file

@ -64,7 +64,7 @@ export function ModelsHeader({
return (
<header className="font-heading flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<PageHeading
title={isDataset ? "Datasets" : "Models"}
title={isDataset ? "Datasets" : "Model hub"}
onTitleClick={onTitleClick}
subtitle={
isDataset

View file

@ -39,7 +39,7 @@ export const ar = {
returnToChat: "العودة إلى المحادثة",
compare: "مقارنة",
search: "بحث",
hub: "النماذج",
hub: "مركز النماذج",
train: "تدريب",
recipes: "الوصفات",
export: "تصدير",

View file

@ -39,7 +39,7 @@ export const de = {
returnToChat: "Zurück zum Chat",
compare: "Vergleichen",
search: "Suchen",
hub: "Modelle",
hub: "Modell-Hub",
train: "Trainieren",
recipes: "Rezepte",
export: "Exportieren",

View file

@ -36,7 +36,7 @@ export const en = {
returnToChat: "Return to Chat",
compare: "Compare",
search: "Search",
hub: "Models",
hub: "Model hub",
train: "Train",
recipes: "Recipes",
export: "Export",

View file

@ -39,7 +39,7 @@ export const es = {
returnToChat: "Volver al chat",
compare: "Comparar",
search: "Buscar",
hub: "Modelos",
hub: "Centro de modelos",
train: "Entrenar",
recipes: "Recetas",
export: "Exportar",

View file

@ -39,7 +39,7 @@ export const fr = {
returnToChat: "Retour à la discussion",
compare: "Comparer",
search: "Rechercher",
hub: "Modèles",
hub: "Hub de modèles",
train: "Entraîner",
recipes: "Recettes",
export: "Exporter",

View file

@ -39,7 +39,7 @@ export const hi = {
returnToChat: "चैट पर लौटें",
compare: "तुलना करें",
search: "खोजें",
hub: "मॉडल",
hub: "मॉडल हब",
train: "ट्रेनिंग",
recipes: "रेसिपी",
export: "एक्सपोर्ट",

View file

@ -40,7 +40,7 @@ export const ja = {
returnToChat: "チャットに戻る",
compare: "比較",
search: "検索",
hub: "モデル",
hub: "モデルハブ",
train: "トレーニング",
recipes: "レシピ",
export: "エクスポート",

View file

@ -39,7 +39,7 @@ export const ko = {
returnToChat: "채팅으로 돌아가기",
compare: "비교",
search: "검색",
hub: "모델",
hub: "모델 허브",
train: "학습",
recipes: "레시피",
export: "내보내기",

View file

@ -39,7 +39,7 @@ export const ptBR = {
returnToChat: "Retornar ao Chat",
compare: "Comparar",
search: "Buscar",
hub: "Modelos",
hub: "Hub de modelos",
train: "Treinar",
recipes: "Receitas",
export: "Exportar",

View file

@ -39,7 +39,7 @@ export const ru = {
returnToChat: "Вернуться к чату",
compare: "Сравнить",
search: "Поиск",
hub: "Модели",
hub: "Хаб моделей",
train: "Обучение",
recipes: "Рецепты",
export: "Экспорт",

View file

@ -39,7 +39,7 @@ export const zhCN = {
returnToChat: "返回聊天",
compare: "对比",
search: "搜索",
hub: "模型",
hub: "模型中心",
train: "训练",
recipes: "配方",
export: "导出",

View file

@ -2,7 +2,21 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { extendTailwindMerge } from "tailwind-merge";
// text-ui-* / leading-ui-* are the scaled typography tokens from index.css.
// Register them as font-size / line-height so twMerge does not treat
// text-ui-* as a text color and drop it when a color class follows.
const isUiToken = (value: string) => /^ui-\d+(p5)?$/.test(value);
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [{ text: [isUiToken] }],
leading: [{ leading: [isUiToken] }],
},
},
});
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));

View file

@ -193,6 +193,25 @@ def main():
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}")
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)

View file

@ -17,6 +17,7 @@ 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")
UTILS = (SRC / "lib/utils.ts").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.
@ -87,6 +88,16 @@ def test_radix_select_viewport_owns_the_scroll_state():
assert "overflow-y-auto" not in content_cls.group(1)
def test_cn_knows_the_ui_typography_tokens():
"""Stock tailwind-merge classifies text-ui-* as a text color and deletes
it whenever a real color class follows in the same cn() call, so the
element falls back to the unscaled inherited font size."""
assert "extendTailwindMerge" in UTILS
assert '"font-size": [{ text: [isUiToken] }]' in UTILS
assert "leading: [{ leading: [isUiToken] }]" in UTILS
assert "/^ui-\\d+(p5)?$/.test(value)" in UTILS
def test_no_raw_pixel_text_utilities():
offenders = []
for path in _frontend_sources():