diff --git a/frontend/src/styles/console/data.css b/frontend/src/styles/console/data.css index 00f4141f50e8..783a24a320dc 100644 --- a/frontend/src/styles/console/data.css +++ b/frontend/src/styles/console/data.css @@ -347,9 +347,14 @@ html.flat-theme .table-wrapper .table-body > tr:where([data-row-id]):is([class~= DataTable's own selection is the row's class; a page's own select column makes the row publish --ui-row-picked (dark theme only, so a light-theme selection never restyles the row's cells), which its cells read through a style query (Chromium 111+, Safari 18+). Firefox has no style queries: it keeps the pre-pass selector in the - @supports (-moz-appearance: none) blocks, which Chromium never loads. Same weights (0,8,4) as the pre-pass rules. */ + @supports (-moz-appearance: none) blocks, which Chromium never loads. Same weights (0,8,4) as the pre-pass rules. + The same row also lifts the data text roles of section 12 (12.4) for its cells, which inherit them: a custom + property needs no style query, so this works in every browser without another relational selector. */ html.flat-theme.dark .table-wrapper .table-body > tr:where([data-row-id]):has(> td[data-column="select"] input[type="checkbox"]:checked) { --ui-row-picked: 1; + --ui-data-secondary: var(--ui-data-picked); + --ui-data-reference: var(--ui-data-picked); + --ui-data-label: var(--ui-data-picked); } html.flat-theme.dark .table-wrapper .table-body > tr[class~="bg-primary-50/40"][class][class]:is(tr):is(tr) :is([class~="text-gray-400"], [class~="text-gray-500"], [class~="dark:text-dark-400"], [class~="dark:text-dark-500"], [data-ui="cell-line-label"]) { @@ -730,18 +735,21 @@ html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text color: rgb(var(--ui-label)); } -/* figures: requests and tokens muted, the actual spend ink, the account cost and the standard price muted (the - title attributes still name them) */ +/* figures (text roles, section 12): requests and tokens are the row's secondary figures, the actual spend its primary + figure, the account cost and the standard price reference values (the title attributes still name them); all of + them Geist Mono, tabular, like the token column (they were Geist next to Mono token counts) */ html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"] { - color: rgb(var(--ui-muted)); + color: rgb(var(--ui-data-secondary)); + font-family: var(--ui-font-mono); + font-variant-numeric: tabular-nums; } -html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:is([class~="text-green-600"]) { - color: rgb(var(--ui-ink)); +html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:is([class~="text-orange-500"], [class~="text-gray-400"]) { + color: rgb(var(--ui-data-reference)); } -html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:nth-child(3) { - font-family: var(--ui-font-mono); +html.flat-theme [class~="max-h-48"][class~="overflow-auto"] > table[class~="text-xs"] > tbody > tr > td[class~="text-right"]:is([class~="text-green-600"]) { + color: rgb(var(--ui-data-primary)); } /* the colour key: an 8px square with 2px corners (REF §8.14 legend and usage rows) in the slice's colour; slices @@ -862,7 +870,8 @@ html.flat-theme [data-ui="pagination"] nav[aria-label="Pagination"] > button { border: 0; border-radius: var(--ui-radius-md); background-color: transparent; - color: rgb(var(--ui-muted)); + /* page numbers are figures: the secondary role (section 12; the sentence around the counts is the label) */ + color: rgb(var(--ui-data-secondary)); font-family: var(--ui-font-mono); font-size: 12px; font-weight: 500; @@ -1893,3 +1902,127 @@ html.flat-theme :where(div:is([class~="bg-primary-50"], [class~="bg-primary-50/5 color: rgb(var(--ui-muted)); } /* END S callouts */ + +/* ================================================================== 12 · text roles in the record tables + + Owner decision 2026-10-01, text option A "two tones" (artifacts/tmp/ui-polish/text/PREVIEW.md, where it was reviewed + and verified against a role table): in the cells and headers of 使用记录 (管理 and 用户, records and error requests), + 运维监控's error dialog, 账号管理, API 密钥, 用户管理 and the distribution tables, and in the accounts toolbar, the + pagination, the records tabs and the IP-region bar, a value is the ink and everything else one grey (tokens.css, data + text roles); before, a row held up to eight greys a step apart. Status, platform / identity, accent and token-glyph + colours stay; every other table keeps its colours. + primary names, e-mails, model, key / account names, token figures, the billed cost, balances, amounts, + priority, the error message; toolbar controls (buttons, selects, filter pills, page size) + labelled a value right after its inline label (管理分类 / 请求路由 / 最近 / 创建 / 过期 / 上游声明倍率 values, the + endpoint path after 入站 / 上游) + secondary unlabelled metadata: times, IPs, User-Agent, ids (#1), the model chain (↳), chips' text, status + words, platform, error category, reasoning effort, the masked key, an account's e-mail, page numbers + reference the account's billed cost (A $), the distribution tables' account cost and standard price + label headers, inline labels, 入站 / 上游, 首字 / 总耗时, units, placeholders (-), counts, the pagination + sentence, idle tabs, empty-value prompts, row-action icons + Inside those boxes the theme's text tokens and upstream's grey text classes resolve to the roles (custom properties + only, declared on each cell so a selected row can lift the tones it holds); an element whose class names another + role is named below or in its page file (accounts.css, dashboard-usage.css, user.css, + users-groups-proxies-keys.css), and the rules that paint a figure, an e-mail, a time or a page number read the role + directly (sections 3 and 4, the page files, ops-channels.css). Static hooks only (data-table, data-column, data-line, data-ui, upstream's static classes); one + joins two + static DataTable hooks (a labelled value). An empty-state row (colspan) is left alone. */ + +/* 12.1 the scope: each cell of the tables above, and the toolbars */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan])), +html.flat-theme :where([data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) { + --ui-ink: var(--ui-data-primary); + --ui-ink-soft: var(--ui-data-secondary); + --ui-muted: var(--ui-data-label); + --ui-faint: var(--ui-data-label); + --ui-label: var(--ui-data-label); + --ui-table-head: var(--ui-data-label); + --ui-badge-muted: var(--ui-data-secondary); +} + +/* a cell's own text (TablePageLayout's scoped td colour, text-gray-700 / dark:text-gray-300: the users id, the accounts + usage notes) is a secondary value */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan])) { + --theme-text-gray-700: var(--ui-data-secondary); +} + +html.flat-theme.dark :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan])) { + --theme-text-gray-300: var(--ui-data-secondary); +} + +/* headers: the label; a sortable label under the pointer takes the primary tone (it was the in-between grey) */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > thead > tr > th) { + --ui-ink: var(--ui-data-primary); + --ui-ink-soft: var(--ui-data-primary); + --ui-muted: var(--ui-data-label); + --ui-faint: var(--ui-data-label); + --ui-label: var(--ui-data-label); + --ui-table-head: var(--ui-data-label); +} + +/* 12.2 upstream's grey text classes -> roles: the light class names the template's intent (900 primary, 500-800 + secondary, 400 label); dark mode reads the same role whatever dark: shade the template pairs with it */ +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan]), [data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) :not(td, th):is([class~="text-gray-900"], [class~="text-gray-950"]) { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan]), [data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) :not(td, th):is([class~="text-gray-800"], [class~="text-gray-700"], [class~="text-gray-600"], [class~="text-gray-500"], [class~="text-slate-500"], [class~="text-slate-600"]) { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + +html.flat-theme :where(:where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) > table > tbody > tr > td:not([colspan]), [data-ui="accounts-toolbar"], [data-ui="pagination"], [data-ui~="usage-records"] > div:first-child, [data-ui="user-usage"] > div[class~="border-b"], [data-ui~="records-card"] > div[class~="justify-end"][class~="border-b"]) :not(td, th):is([class~="text-gray-400"], [class~="text-slate-400"]) { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +/* 12.3 elements whose role differs from their class (the tables' own ones are in the page files) */ +/* a value right after its own inline label (DataTable's stacked lines: 最近 / 创建 / 过期, a scheduling group ...) is a + labelled value, one step above its label; primary values and placeholders keep their roles (the one sibling + combinator, keyed on DataTable's static data-ui hooks) */ +html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) [data-ui="cell-line-label"] + [data-ui="cell-line-value"] :is([class~="text-gray-800"], [class~="text-gray-700"], [class~="text-gray-600"], [class~="text-gray-500"], [class~="text-slate-500"], [class~="text-slate-600"]) { + --theme-text-gray-100: var(--ui-data-labelled); --theme-text-gray-200: var(--ui-data-labelled); --theme-text-gray-300: var(--ui-data-labelled); + --theme-text-gray-400: var(--ui-data-labelled); --theme-text-gray-500: var(--ui-data-labelled); --theme-text-gray-600: var(--ui-data-labelled); + --theme-text-gray-700: var(--ui-data-labelled); --theme-text-gray-800: var(--ui-data-labelled); --theme-text-gray-900: var(--ui-data-labelled); + --theme-text-gray-950: var(--ui-data-labelled); --theme-text-dark-100: var(--ui-data-labelled); --theme-text-dark-200: var(--ui-data-labelled); + --theme-text-dark-300: var(--ui-data-labelled); --theme-text-dark-400: var(--ui-data-labelled); --theme-text-dark-500: var(--ui-data-labelled); + --theme-text-dark-600: var(--ui-data-labelled); --ui-ink-soft: var(--ui-data-labelled); +} + +/* row actions are icon buttons with a caption: they keep the label tone whatever grey class they carry */ +html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) td[data-column="actions"] :is([class~="text-gray-400"], [class~="text-gray-500"], [class~="text-gray-600"], [class~="text-gray-700"]) { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +/* stacked-cell line labels stay labels in a selected row too (section 1 draws them there with --ui-ink-soft) */ +html.flat-theme :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"]), [data-ui="dist-table"]) [data-ui="cell-line-label"] { + --ui-ink-soft: var(--ui-data-label); +} + +/* 12.4 a selected row (dark): on the selected fill (#39433f, #404946 under the pointer) the grey #a3a3ac is 4.10:1 / + 3.71, so the row lifts its secondary, reference and label tones to --ui-data-picked (#b8b8c0, 5.20 / 4.72), as + section 1 lifts upstream's greys there. DataTable's own selection is a class on the row (below); a page's own + select column is the checked box section 1's row rule already tests, and that rule sets the three roles on the row, + whose cells inherit them (no style query, no further relational selector, the same in Firefox). */ +html.flat-theme.dark :where(:is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper, .table-wrapper:is([data-table="accounts"], [data-table="keys"], [data-table="users"])) > table > tbody.table-body > tr[class~="bg-primary-50/40"] > td { + --ui-data-secondary: var(--ui-data-picked); + --ui-data-reference: var(--ui-data-picked); + --ui-data-label: var(--ui-data-picked); +} diff --git a/frontend/src/styles/console/pages/accounts.css b/frontend/src/styles/console/pages/accounts.css index 90514a177ab3..8cac19876c2b 100644 --- a/frontend/src/styles/console/pages/accounts.css +++ b/frontend/src/styles/console/pages/accounts.css @@ -102,6 +102,17 @@ html.flat-theme [data-ui="accounts-toolbar"] > [data-ui="account-filters"] { gap: 6px; } +/* The filter pills are controls (text roles, data.css 12: the toolbar's grey is the placeholder's): drawn like the + buttons and selects beside them, in the ink; under the pointer a pill takes the focus line, as the labelled field + pills of 使用记录 do (an active pill keeps its accent edge: this weighs less). */ +html.flat-theme [data-ui="accounts-toolbar"] [data-ui~="filter-pill"] { + --ui-ink-soft: var(--ui-data-primary); +} + +html.flat-theme :where([data-ui="accounts-toolbar"]) [data-ui~="filter-pill"]:hover { + border-color: rgb(var(--ui-line-focus)); +} + /* The route-group select stands among 30px pills: the recipe's house select at the pill height. */ html.flat-theme [data-ui="account-filters"] select.input { height: var(--ui-control-h); @@ -444,7 +455,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="name"] > d min-width: 3em; font-size: 12px; line-height: 18px; - color: rgb(var(--ui-muted)); + /* the e-mail under the name is a secondary value (data.css 12) */ + color: rgb(var(--ui-data-secondary)); } /* Integration review: a name that links to the account's homepage sits in HelpTooltip's root, which keeps its own @@ -647,6 +659,30 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="actions"] } } +/* Text roles (data.css 12) of the accounts table whose template class names another role: + - 管理分类 and 请求路由 are inline labels (template spans); the folder and the route summary are their values; + - priority, scheduling weight and the account rate are figures (primary); 「未探测」 is the labelled value of + 上游声明倍率 (its class says label). */ +html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="taxonomy_route"] span[class~="font-medium"][class~="text-gray-700"], +html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="taxonomy_route"] div[class~="truncate"][class~="text-gray-500"], +html.flat-theme .table-wrapper[data-table="accounts"] [data-ui="cell-line"][data-line="upstream_billing_rate"] [class~="font-medium"][class~="text-gray-400"] { + --theme-text-gray-100: var(--ui-data-labelled); --theme-text-gray-200: var(--ui-data-labelled); --theme-text-gray-300: var(--ui-data-labelled); + --theme-text-gray-400: var(--ui-data-labelled); --theme-text-gray-500: var(--ui-data-labelled); --theme-text-gray-600: var(--ui-data-labelled); + --theme-text-gray-700: var(--ui-data-labelled); --theme-text-gray-800: var(--ui-data-labelled); --theme-text-gray-900: var(--ui-data-labelled); + --theme-text-gray-950: var(--ui-data-labelled); --theme-text-dark-100: var(--ui-data-labelled); --theme-text-dark-200: var(--ui-data-labelled); + --theme-text-dark-300: var(--ui-data-labelled); --theme-text-dark-400: var(--ui-data-labelled); --theme-text-dark-500: var(--ui-data-labelled); + --theme-text-dark-600: var(--ui-data-labelled); --ui-ink-soft: var(--ui-data-labelled); +} + +html.flat-theme .table-wrapper[data-table="accounts"] [data-ui="cell-line"]:is([data-line="priority"], [data-line="scheduler_score"], [data-line="rate_multiplier"]) > [data-ui="cell-line-value"] [class~="text-gray-700"] { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + /* ================================================================== 6 · card grid and compact list */ /* Shared parts of the two other views: the 14px checkbox with its 32px hit area (as in the table), the account id diff --git a/frontend/src/styles/console/pages/dashboard-usage.css b/frontend/src/styles/console/pages/dashboard-usage.css index 40c2eba962b1..ec41f00f5df1 100644 --- a/frontend/src/styles/console/pages/dashboard-usage.css +++ b/frontend/src/styles/console/pages/dashboard-usage.css @@ -416,12 +416,13 @@ html.flat-theme :is(.table-wrapper[data-table="usage"], [data-ui="admin-usage"] color: rgb(var(--ui-ink-soft)); } -/* Hierarchy by colour and weight instead of more black (palette C): the requested model and the billed cost are the - row's ink (500); every token figure (input, output, cache, image) ink-soft; the key, account and reasoning names - ink-soft 400; the endpoint's 「入站 / 上游」 labels 400; times and IPs tabular figures. The token glyphs get upstream's - hues back as identity-safe text colours (input emerald, output violet, cache read sky, cache write amber, image - fuchsia / pink, indigo), so the figures stay neutral and the colour says which figure is which; the account's billed - cost stays muted. The key, account, reasoning and endpoint rules also name their lines of a stacked cell +/* Hierarchy by colour and weight instead of more black (palette C, then the text roles of data.css 12): the requested + model and the billed cost are the row's ink (500); every token figure (input, output, cache, image) the ink at 400; + the key and account names 400 (the primary role), the reasoning effort the secondary one; the endpoint's + 「入站 / 上游」 labels 400; times and IPs tabular figures. The token glyphs get upstream's hues back as identity-safe + text colours (input emerald, output violet, cache read sky, cache write amber, image fuchsia / pink, indigo), so the + figures stay neutral and the colour says which figure is which; the account's billed cost is a reference value (the + grey). The key, account, reasoning and endpoint rules also name their lines of a stacked cell ([data-ui="cell-line"][data-line], the table-width track's stacked layout), as that track does for the rules it carries over. */ html.flat-theme .table-wrapper[data-table="usage"] td:is([data-column="api_key"], [data-column="account"], [data-column="reasoning_effort"]) span[class~="text-gray-900"], @@ -430,9 +431,15 @@ html.flat-theme .table-wrapper[data-table="usage"] [data-ui="cell-line"]:is([dat font-weight: 400; } +/* (text roles, data.css 12: every token figure is a primary figure, in the ink like the billed cost, Geist Mono 13 / 400 + tabular; the billed cost keeps 500, the one weighted figure of the row. They were ink-soft Mono 13 / 500.) */ html.flat-theme .table-wrapper[data-table="usage"] td[data-column="tokens"] span[class~="font-medium"][class~="text-gray-900"], html.flat-theme .table-wrapper[data-table="usage"] td[data-column="tokens"] :is([class~="text-sky-600"], [class~="text-amber-600"], [class~="text-fuchsia-600"], [class~="text-pink-600"]) { - color: rgb(var(--ui-ink-soft)); + color: rgb(var(--ui-data-primary)); + font-family: var(--ui-font-mono); + font-size: 13px; + font-weight: 400; + font-variant-numeric: tabular-nums; } html.flat-theme .table-wrapper[data-table="usage"] td[data-column="cost"] span[class~="text-green-600"] { @@ -476,8 +483,83 @@ html.flat-theme .table-wrapper[data-table="usage"] td[data-column="tokens"] svg[ color: rgb(var(--ui-id-indigo)); } +/* the account's billed cost (A $) is a reference value next to the billed cost: the grey, a secondary figure one pixel + under the row's 13px figures (it was 11px) */ html.flat-theme .table-wrapper[data-table="usage"] td[data-column="cost"] div[class~="text-orange-500"] { - color: rgb(var(--ui-muted)); + color: rgb(var(--ui-data-reference)); + font-size: 12px; + line-height: 18px; +} + +/* Text roles (data.css 12) of the records and error lists whose class names another role: + - reasoning effort qualifies the model: secondary (its class says primary; dark: its dark:text-white reads + --theme-on-fill, the ink inside tables, so that is moved too, on these text leaves only); + - 入站 / 上游: the endpoint path is the labelled value, 「入站:」 / 「上游:」 its label (their classes say secondary); + - error lists: the message is the row's content (primary); platform and category are secondary. + The secondary figures (latency, IP) are Geist Mono 12, a pixel under the primary 13px figures (latency was Geist + sans 12 / 500, the IP Mono 13). */ +html.flat-theme .table-wrapper[data-table="usage"] :is(td[data-column="reasoning_effort"], [data-ui="cell-line"][data-line="reasoning_effort"]) :is(span, div)[class~="text-gray-900"] { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + +html.flat-theme.dark .table-wrapper[data-table="usage"] :is(td[data-column="reasoning_effort"], [data-ui="cell-line"][data-line="reasoning_effort"]) :is(span, div)[class~="text-gray-900"] { + --theme-on-fill: var(--ui-data-secondary); +} + +html.flat-theme :is(.table-wrapper[data-table="usage"], :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"])) :is(td[data-column="endpoint"], [data-ui="cell-line"][data-line="endpoint"]) div[class~="break-all"] { + --theme-text-gray-100: var(--ui-data-labelled); --theme-text-gray-200: var(--ui-data-labelled); --theme-text-gray-300: var(--ui-data-labelled); + --theme-text-gray-400: var(--ui-data-labelled); --theme-text-gray-500: var(--ui-data-labelled); --theme-text-gray-600: var(--ui-data-labelled); + --theme-text-gray-700: var(--ui-data-labelled); --theme-text-gray-800: var(--ui-data-labelled); --theme-text-gray-900: var(--ui-data-labelled); + --theme-text-gray-950: var(--ui-data-labelled); --theme-text-dark-100: var(--ui-data-labelled); --theme-text-dark-200: var(--ui-data-labelled); + --theme-text-dark-300: var(--ui-data-labelled); --theme-text-dark-400: var(--ui-data-labelled); --theme-text-dark-500: var(--ui-data-labelled); + --theme-text-dark-600: var(--ui-data-labelled); --ui-ink-soft: var(--ui-data-labelled); +} + +html.flat-theme :is(.table-wrapper[data-table="usage"], :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"])) :is(td[data-column="endpoint"], [data-ui="cell-line"][data-line="endpoint"]) div[class~="break-all"] > span[class~="font-medium"] { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +html.flat-theme :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) td[data-column="message"] > span[class~="text-gray-600"] { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + +html.flat-theme :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) :is(td[data-column="platform"], td[data-column="category"]) > span[class~="text-gray-900"], +html.flat-theme :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) [data-ui="cell-line"]:is([data-line="platform"], [data-line="category"]) > [data-ui="cell-line-value"] > span[class~="text-gray-900"] { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + +html.flat-theme.dark :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) :is(td[data-column="platform"], td[data-column="category"]) > span[class~="text-gray-900"], +html.flat-theme.dark :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"]) [data-ui="cell-line"]:is([data-line="platform"], [data-line="category"]) > [data-ui="cell-line-value"] > span[class~="text-gray-900"] { + --theme-on-fill: var(--ui-data-secondary); +} + +html.flat-theme .table-wrapper[data-table="usage"] td[data-column="latency"] span[class~="tabular-nums"] { + font-family: var(--ui-font-mono); + font-size: 12px; +} + +html.flat-theme :is(.table-wrapper[data-table="usage"], :is([data-ui~="usage-records"], [data-ui~="ops-error-list"], [data-ui="user-usage"]) .table-wrapper:not([data-table="usage"])) td:is([data-column="ip_address"], [data-column="client_ip"]) span[class~="font-mono"] { + font-size: 12px; } /* The token and cost breakdown cards (UsageTable's two portals; the frame is overlays.css 2.3): the billed amounts and diff --git a/frontend/src/styles/console/pages/ops-channels.css b/frontend/src/styles/console/pages/ops-channels.css index c281d53086d8..a0eab0901655 100644 --- a/frontend/src/styles/console/pages/ops-channels.css +++ b/frontend/src/styles/console/pages/ops-channels.css @@ -1698,13 +1698,14 @@ html.flat-theme [data-ui~="ops-error-list"] > div:last-child > div[class~="mb-2" color: rgb(var(--ui-label)); } -/* the list's cells: time Mono; the type and group chips are categories (data.css' category-chip hook, added to - OpsErrorLogTable.vue: the reference's neutral chip); the detail action a 28px icon button */ +/* the list's cells: time Mono, a secondary value (data.css 12, as in the other lists); the type and group chips are + categories (data.css' category-chip hook, added to OpsErrorLogTable.vue: the reference's neutral chip); the detail + action a 28px icon button */ html.flat-theme [data-ui~="ops-error-list"] .table-wrapper td[data-column="created_at"] > span { font-family: var(--ui-font-mono); font-size: 12px; font-variant-numeric: tabular-nums; - color: rgb(var(--ui-muted)); + color: rgb(var(--ui-data-secondary)); } html.flat-theme [data-ui~="ops-error-list"] .table-wrapper td[data-column="actions"] > button { diff --git a/frontend/src/styles/console/pages/user.css b/frontend/src/styles/console/pages/user.css index f668173f921a..9e87dc9f2829 100644 --- a/frontend/src/styles/console/pages/user.css +++ b/frontend/src/styles/console/pages/user.css @@ -127,13 +127,32 @@ html.flat-theme .table-wrapper[data-table="keys"] td[data-column="usage"] span[c font-variant-numeric: tabular-nums; } -/* the labels (今日 / 近30天) in ink-soft, the reference's colour for a row's secondary figures: muted sits at 4.5:1 - on a hovered row, too close to the floor for a 12px label */ +/* the labels (今日 / 近30天): the label role of the table's text (data.css 12; #66666e is 4.95:1 on a hovered row) */ html.flat-theme .table-wrapper[data-table="keys"] td[data-column="usage"] span[class~="text-gray-500"] { - color: rgb(var(--ui-ink-soft)); + color: rgb(var(--ui-data-label)); font-size: 12px; } +/* text roles (data.css 12) whose class names another role: 「选择分组」 is an empty-value prompt (a label); 「永久有效」 is + a value (secondary, its class says label) */ +html.flat-theme .table-wrapper[data-table="keys"] td[data-column="group"] [class~="text-gray-500"] { + --theme-text-gray-100: var(--ui-data-label); --theme-text-gray-200: var(--ui-data-label); --theme-text-gray-300: var(--ui-data-label); + --theme-text-gray-400: var(--ui-data-label); --theme-text-gray-500: var(--ui-data-label); --theme-text-gray-600: var(--ui-data-label); + --theme-text-gray-700: var(--ui-data-label); --theme-text-gray-800: var(--ui-data-label); --theme-text-gray-900: var(--ui-data-label); + --theme-text-gray-950: var(--ui-data-label); --theme-text-dark-100: var(--ui-data-label); --theme-text-dark-200: var(--ui-data-label); + --theme-text-dark-300: var(--ui-data-label); --theme-text-dark-400: var(--ui-data-label); --theme-text-dark-500: var(--ui-data-label); + --theme-text-dark-600: var(--ui-data-label); --ui-ink-soft: var(--ui-data-label); +} + +html.flat-theme .table-wrapper[data-table="keys"] td[data-column="expires_at"] > span[class~="text-gray-400"] { + --theme-text-gray-100: var(--ui-data-secondary); --theme-text-gray-200: var(--ui-data-secondary); --theme-text-gray-300: var(--ui-data-secondary); + --theme-text-gray-400: var(--ui-data-secondary); --theme-text-gray-500: var(--ui-data-secondary); --theme-text-gray-600: var(--ui-data-secondary); + --theme-text-gray-700: var(--ui-data-secondary); --theme-text-gray-800: var(--ui-data-secondary); --theme-text-gray-900: var(--ui-data-secondary); + --theme-text-gray-950: var(--ui-data-secondary); --theme-text-dark-100: var(--ui-data-secondary); --theme-text-dark-200: var(--ui-data-secondary); + --theme-text-dark-300: var(--ui-data-secondary); --theme-text-dark-400: var(--ui-data-secondary); --theme-text-dark-500: var(--ui-data-secondary); + --theme-text-dark-600: var(--ui-data-secondary); --ui-ink-soft: var(--ui-data-secondary); +} + /* the live-concurrency count: the reference's cell chip (4px, Mono), a running count keeps its success tint */ html.flat-theme .table-wrapper[data-table="keys"] td[data-column="current_concurrency"] > span { min-width: 28px; diff --git a/frontend/src/styles/console/pages/users-groups-proxies-keys.css b/frontend/src/styles/console/pages/users-groups-proxies-keys.css index ff6422f1d681..146748f9acfa 100644 --- a/frontend/src/styles/console/pages/users-groups-proxies-keys.css +++ b/frontend/src/styles/console/pages/users-groups-proxies-keys.css @@ -296,6 +296,16 @@ html.flat-theme :is([data-ui~="redeem-page"], [data-ui~="promo-page"]) td[data-c /* ================================================================== 4 · users table */ +/* the username is a name: the primary role of the table's text (data.css 12; its class says secondary) */ +html.flat-theme .table-wrapper[data-table="users"] td[data-column="username"] > span[class~="text-gray-700"] { + --theme-text-gray-100: var(--ui-data-primary); --theme-text-gray-200: var(--ui-data-primary); --theme-text-gray-300: var(--ui-data-primary); + --theme-text-gray-400: var(--ui-data-primary); --theme-text-gray-500: var(--ui-data-primary); --theme-text-gray-600: var(--ui-data-primary); + --theme-text-gray-700: var(--ui-data-primary); --theme-text-gray-800: var(--ui-data-primary); --theme-text-gray-900: var(--ui-data-primary); + --theme-text-gray-950: var(--ui-data-primary); --theme-text-dark-100: var(--ui-data-primary); --theme-text-dark-200: var(--ui-data-primary); + --theme-text-dark-300: var(--ui-data-primary); --theme-text-dark-400: var(--ui-data-primary); --theme-text-dark-500: var(--ui-data-primary); + --theme-text-dark-600: var(--ui-data-primary); --ui-ink-soft: var(--ui-data-primary); +} + /* Balance: the amount is a link to the balance history (the dashed underline in the control line, ink on hover); 「充值」 under it (B1.8) is the reference's link, the accent text at 12px, underlined on hover (upstream's hover fill goes). */ html.flat-theme [data-ui~="users-page"] td[data-column="balance"] div[class~="group"] > button { diff --git a/frontend/src/styles/console/tables.css b/frontend/src/styles/console/tables.css index 0d22fe13ed37..2f0b3f8090e4 100644 --- a/frontend/src/styles/console/tables.css +++ b/frontend/src/styles/console/tables.css @@ -217,12 +217,29 @@ html.flat-theme .table-wrapper[data-table="accounts"] > table > tbody.table-body margin-top: 1px; } -/* ---- 名称 / 邮箱 / #ID (/ 备注): the name (600) over its e-mail, both complete; the template puts before the @ - and inside a long local part (NightingaleGiovanna7598 -> Nightingale · Giovanna · 7598), so an address breaks at a - word, never inside one */ +/* ---- 名称 / 邮箱 / #ID (/ 备注): the name (500) over its e-mail, both complete; the template puts before the @, + after a URL's slashes and at the joins of a word of letters (NightingaleGiovanna7598 -> Nightingale · Giovanna · + 7598), never inside a code (the 18 characters of cindy-global-am332d23b406hthnne-…) or a host name, so an address or + a name breaks at a word. + The column follows the page's names (owner 2026-10-01, 「名称这里你留这么大的空白干啥」「账号名称空白太多」). + AccountsView measures the page's names, e-mails and #ids in their own fonts and binds --account-name-w, the width at + which they leave the least blank, an extra line counting as 40px of blank (softWrap.ts nameColumnWidth): a page of + e-mails takes their local part's width, a page of cindy names their two-line width, and one longer name or e-mail + takes a third line at its word joins instead of widening the column for the whole page; never narrower than the + widest piece, --account-name-piece. The width depends on the page's names only, so it is the same at every screen + width (1440, 1920, the sidebar collapsed): the rest of the table goes to the other columns in proportion (the + browser's auto layout). Where even that width does not fit (all 21 columns), the browser narrows the column down to + its widest piece, at most 10rem (every code and e-mail part is narrower: only a longer piece, a host name without a + break, may then break inside, so the table never scrolls sideways), and a long name takes another line. Unmeasured + (no canvas), the column is the browser's, from 9rem. */ +html.flat-theme .table-wrapper[data-table="accounts"] { + --account-name-col: calc(var(--account-name-w) + 2 * var(--ui-table-cell-x)); + --account-name-floor: calc(min(var(--account-name-piece), 10rem) + 2 * var(--ui-table-cell-x)); +} + html.flat-theme .table-wrapper[data-table="accounts"] :is(th, td)[data-column="name"] { - width: auto; - min-width: 9rem; + width: var(--account-name-col, auto); + min-width: var(--account-name-floor, 9rem); max-width: none; } @@ -241,7 +258,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="name"] > d overflow: visible; white-space: normal; text-overflow: clip; - overflow-wrap: break-word; + /* (the column's floor keeps every piece up to 10rem whole; only a longer one, a URL, may break inside) */ + overflow-wrap: anywhere; } /* a name that links to its site keeps its dotted underline on every line it wraps to */ @@ -263,6 +281,8 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="name"] div overflow: visible; white-space: normal; text-overflow: clip; + /* a note's long token (exported=2026-…) does not hold the column wider than its names need */ + overflow-wrap: anywhere; } /* ---- 平台/类型 / 容量: the identity chips (the joined platform | type chip on one line when it fits), the compact-mode @@ -414,6 +434,13 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="usage"] sp text-overflow: clip; } +/* the cell's hover tips (Gemini quota policy, ineligible warning: 20rem, invisible until hovered) open leftwards from + their icon; opening rightwards, the hidden box widened the table's scroll area (1280: 17px of sideways scrolling) */ +html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="usage"] [class~="group"] > span[class~="absolute"][class~="top-full"][class~="w-80"] { + left: auto; + right: 0; +} + html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="usage"] div[data-line="today_stats"] { flex-direction: column; align-items: flex-start; @@ -501,10 +528,24 @@ html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="actions"] } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="platform_type"] { min-width: 6rem; } - html.flat-theme .table-wrapper[data-table="accounts"] :is(th, td)[data-column="name"] { min-width: 8rem; } + /* the name column's floor is today's 8rem (a 7rem piece and the insets): with all 21 columns at 1280 a page whose other + cells cannot narrow (a temporarily unschedulable account, OAuth plan chips) leaves the name less than its widest + code, which then breaks inside rather than the table scrolling sideways (page 41: the other columns are at their + floors, so every pixel more of this floor was a pixel of sideways scrolling: 8rem 5px, 9rem 21px) */ + html.flat-theme .table-wrapper[data-table="accounts"] { + --account-name-floor: calc(min(var(--account-name-piece), 7rem) + 2 * var(--ui-table-cell-x)); + } + + html.flat-theme .table-wrapper[data-table="accounts"] :is(th, td)[data-column="name"] { min-width: var(--account-name-floor, 8rem); } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="taxonomy_route"] { min-width: 7rem; } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="usage"] { min-width: 9.5rem; } html.flat-theme .table-wrapper[data-table="accounts"] th[data-column="last_used_at"] { min-width: 7rem; } + + /* all 21 columns: 调度权值's group / score / limit figures (「commandcode / 3.75 / +∞」) wrap at their 「/」 where the + column is short of room (the line pushed the table sideways at 1280) */ + html.flat-theme .table-wrapper[data-table="accounts"] td[data-column="priority"] div[data-line="scheduler_score"] > div[data-ui="cell-line-value"] > div > div { + flex-wrap: wrap; + } } /* ================================================================== 3 · lines whose layout differs from their column's diff --git a/frontend/src/styles/console/tokens.css b/frontend/src/styles/console/tokens.css index b2f83dff17d7..401ab5fff46d 100644 --- a/frontend/src/styles/console/tokens.css +++ b/frontend/src/styles/console/tokens.css @@ -92,12 +92,24 @@ /* Text (zinc). ink #1f1f23 (16.4:1 on white; #0a0a0a read black and heavy in dense rows); ink-soft #52525b (values, filter pills, tooltip descriptions, secondary figures); muted and faint (placeholders, captions) #66666e: these hold >= 4.5:1 on every neutral fill (surface, canvas, the grouped fill, hover). --ui-label, the - mono label's grey, #6e6e76 holds it on the fills labels sit on (surface 5.05, canvas 4.84, grouped 4.60). */ + mono label's grey, is the muted grey too (owner 2026-10-01, text option A: the KPI, field and section labels + and the table headers are one grey; the former #6e6e76 was 4.40:1 on the row hover, #66666e is 4.95). */ --ui-ink: 31 31 35; --ui-ink-soft: 82 82 91; --ui-muted: 102 102 110; --ui-faint: 102 102 110; - --ui-label: 110 110 118; + --ui-label: 102 102 110; + /* Data text roles (owner 2026-10-01, text option A "two tones": artifacts/tmp/ui-polish/text/PREVIEW.md), read by + data.css 12 inside the cells, headers and toolbars of the record tables: a value is the ink, everything else one + grey, both existing tones (no grey in between). primary = names, figures, the billed cost, controls; labelled = + a value after its inline label; secondary = unlabelled metadata (times, IPs, ids, chips, status words); + reference = the account's billed cost, a distribution's account cost and standard price; label = headers, + inline labels, units, placeholders, counts. */ + --ui-data-primary: 31 31 35; + --ui-data-labelled: 31 31 35; + --ui-data-secondary: 102 102 110; + --ui-data-reference: 102 102 110; + --ui-data-label: 102 102 110; /* Accent (the green #168a49 carries white at 4.41:1). The fill is #168848 (white 4.52:1, REF-CONSOLE §13); accent text and links are #147c42, the nearest at >= 4.5:1 on every neutral fill and on the soft fill. */ --ui-primary: 22 136 72; @@ -213,6 +225,15 @@ /* faint and the label hold >= 4.5:1 on the canvas, the surface, the grouped fill and hover */ --ui-faint: 160 160 169; --ui-label: 163 163 172; + /* data text roles (see light): the ink and the label grey #a3a3ac, the table headers' grey (6.41:1 on the surface, + 5.32 on the row hover). On the selected fill (#39433f, 4.10:1) a row lifts its grey to the soft ink #b8b8c0 + (--ui-data-picked: 5.20:1, 4.72 under the pointer; data.css 12). */ + --ui-data-primary: 232 232 236; + --ui-data-labelled: 232 232 236; + --ui-data-secondary: 163 163 172; + --ui-data-reference: 163 163 172; + --ui-data-label: 163 163 172; + --ui-data-picked: 184 184 192; /* the mint accent with ink on it; soft = mint at 16 % over the surface */ --ui-primary: 182 247 200; --ui-primary-hover: 166 225 183; diff --git a/frontend/src/utils/__tests__/softWrap.spec.ts b/frontend/src/utils/__tests__/softWrap.spec.ts new file mode 100644 index 000000000000..8b86f5193477 --- /dev/null +++ b/frontend/src/utils/__tests__/softWrap.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { nameColumnWidth, nameLinePieces, nameLines, nameRuns, nameWrapParts, type NameCell } from '../softWrap' + +// one unit per character: enough to tell which split the widths come from +const chars = (text: string) => text.length +const cell = (text: string): NameCell => ({ texts: [nameRuns(text, chars)] }) + +describe('nameWrapParts', () => { + it('breaks an e-mail at its words and before the @', () => { + expect(nameWrapParts('NightingaleGiovanna7598@hotmail.com')).toEqual(['Nightingale', 'Giovanna', '7598', '@hotmail.com']) + expect(nameWrapParts('Aetin59137@outlook.com')).toEqual(['Aetin', '59137', '@outlook.com']) + }) + + it('keeps a word with figures inside it whole', () => { + expect(nameWrapParts('eucadc5ca109engnmw@outlook.com')).toEqual(['eucadc5ca109engnmw', '@outlook.com']) + expect(nameWrapParts('cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c')).toEqual([ + 'cindy-global-am332d23b406hthnne-dylancooper', + '2840-2dc78b3c' + ]) + expect(nameWrapParts('')).toEqual([]) + }) + + it('breaks a URL after its slashes, never at a dot of its host', () => { + expect(nameWrapParts('claude-https://api2.aigcbest.top/v1')).toEqual(['claude-https://', 'api2.aigcbest.top/', 'v1']) + }) +}) + +describe('nameLinePieces', () => { + it('lays a cindy name out at its hyphens, never inside its code', () => { + expect(nameLinePieces('cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c')).toEqual([ + 'cindy-', 'global-', 'am332d23b406hthnne-', 'dylancooper', '2840-', '2dc78b3c' + ]) + }) + + it('breaks after a hyphen, not inside a range of figures', () => { + expect(nameLinePieces('claude-3-5-sonnet')).toEqual(['claude-', '3-5-', 'sonnet']) + expect(nameLinePieces('ab-1-2')).toEqual(['ab-', '1-2']) + }) + + it('cuts a URL after its slashes and its hyphens', () => { + expect(nameLinePieces('claude-https://api2.aigcbest.top/v1')).toEqual(['claude-', 'https://', 'api2.aigcbest.top/', 'v1']) + }) +}) + +describe('nameLines', () => { + it('ends each line at the last join that fits', () => { + const mail = nameRuns('NightingaleGiovanna7598@hotmail.com', chars) + expect(nameLines(mail, 35)).toEqual({ lines: 1, widest: 35 }) + expect(nameLines(mail, 23)).toEqual({ lines: 2, widest: 23 }) + // NightingaleGiovanna | 7598@hotmail.com: an e-mail wider than the column breaks at its word joins too + expect(nameLines(mail, 19)).toEqual({ lines: 2, widest: 19 }) + // Nightingale | Giovanna7598 | @hotmail.com + expect(nameLines(mail, 12)).toEqual({ lines: 3, widest: 12 }) + }) + + it('hangs a space at the line end', () => { + // 「openai_28 」 | 「(Copy)」: the first line is 9 wide, its space not counted + expect(nameLines(nameRuns('openai_28 (Copy)', chars), 10)).toEqual({ lines: 2, widest: 9 }) + }) +}) + +describe('nameColumnWidth', () => { + it('takes the width of the page\'s usual e-mails; a longer one takes a third line', () => { + const page = [...Array.from({ length: 10 }, () => cell('Abcde12345@outlook.com')), cell('NightingaleGiovanna7598@hotmail.com')] + // every Abcde12345 | @outlook.com fills 12; the long one is Nightingale | Giovanna7598 | @hotmail.com, not a + // 19-wide column for the whole page + expect(nameColumnWidth(page)).toEqual({ width: 12, piece: 12 }) + expect(nameLines(page[10].texts[0], 12).lines).toBe(3) + }) + + it('never splits a code, so its width is the floor', () => { + const page = [...Array.from({ length: 5 }, () => cell('cun_001')), cell('eucadc5ca109engnmw@outlook.com')] + expect(nameColumnWidth(page)).toEqual({ width: 18, piece: 18 }) + }) + + it('keeps names on two lines where a narrower column would cost each a line', () => { + const page = Array.from({ length: 3 }, () => cell('cindy-global-am332d23b406hthnne-dylancooper2840-2dc78b3c')) + // cindy-global-am332d23b406hthnne- | dylancooper2840-2dc78b3c + expect(nameColumnWidth(page)).toEqual({ width: 32, piece: 19 }) + }) + + it('counts the #id and is empty without names', () => { + expect(nameColumnWidth([{ texts: [nameRuns('kimi', chars)], fixed: 6 }])).toEqual({ width: 6, piece: 4 }) + expect(nameColumnWidth([])).toEqual({ width: 0, piece: 0 }) + }) + + it('lets a token wider than the cap break inside rather than set the column', () => { + const page = [...Array.from({ length: 5 }, () => cell('cun_0001')), cell('x'.repeat(40))] + expect(nameColumnWidth(page)).toEqual({ width: 40, piece: 40 }) + expect(nameColumnWidth(page, { pieceCap: 16 })).toEqual({ width: 16, piece: 40 }) + }) +}) diff --git a/frontend/src/utils/softWrap.ts b/frontend/src/utils/softWrap.ts index 7dc2508b99c0..b757559805b2 100644 --- a/frontend/src/utils/softWrap.ts +++ b/frontend/src/utils/softWrap.ts @@ -4,35 +4,194 @@ const isLower = (c: string | undefined) => c !== undefined && c >= 'a' && c <= 'z' const isUpper = (c: string | undefined) => c !== undefined && c >= 'A' && c <= 'Z' const isDigit = (c: string | undefined) => c !== undefined && c >= '0' && c <= '9' +const isLetter = (c: string | undefined) => isLower(c) || isUpper(c) +const isWordChar = (c: string | undefined) => isLetter(c) || isDigit(c) +const isCJK = (c: string | undefined) => c !== undefined && /[\u3400-\u9fff\uf900-\ufaff]/.test(c) /** - * An account name or e-mail breaks at a word, never inside one: before the @, where a lower-case letter meets a - * capital (NightingaleGiovanna -> Nightingale | Giovanna) and where a letter meets a run of three or more digits - * (Giovanna7598 -> Giovanna | 7598). + * An account name or e-mail breaks at a word, never inside one: before the @, after a slash that a letter follows (a + * URL's https:// | api2.aigcbest.top/ | v1; Chromium does not break there by itself), and inside a word of letters + * (with figures only at its end) where a lower-case letter meets a capital (NightingaleGiovanna -> Nightingale | + * Giovanna) and before its final run of three or more figures (Giovanna7598 -> Giovanna | 7598). A word with figures + * inside it is a code and stays whole (the 18 characters of cindy-global-am332d23b406hthnne-…, + * eucadc5ca109engnmw@outlook.com), and so does a host name (no break at its dots). */ export function nameWrapParts(value: unknown): string[] { const text = value == null ? '' : String(value) if (!text) return [] + const cuts: number[] = [] + for (let start = 0; start < text.length; ) { + if (!isWordChar(text[start])) { + if (text[start] === '@' && start > 0) cuts.push(start) + if (text[start] === '/' && isLetter(text[start + 1])) cuts.push(start + 1) + start++ + continue + } + let end = start + while (end < text.length && isWordChar(text[end])) end++ + let figures = end + while (figures > start && isDigit(text[figures - 1])) figures-- + let letters = figures > start + for (let i = start; letters && i < figures; i++) letters = isLetter(text[i]) + if (letters) { + for (let i = start + 1; i < figures; i++) if (isLower(text[i - 1]) && isUpper(text[i])) cuts.push(i) + if (end - figures >= 3) cuts.push(figures) + } + start = end + } const parts: string[] = [] - let start = 0 + let from = 0 + for (const at of cuts) { + parts.push(text.slice(from, at)) + from = at + } + parts.push(text.slice(from)) + return parts +} + +/** + * Whether the browser may break after the hyphen before text[i]: before a letter, a CJK character or a figure, but not + * inside a range of figures (the 1-2 of ab-1-2, the 3-5 of claude-3-5). A subset of what Chromium does (it also breaks + * before a bracket, say), so a width worked out from it never needs a break the browser does not make. + */ +function breaksAfterHyphen(text: string, i: number): boolean { + const ch = text[i] + if (isLetter(ch) || isCJK(ch)) return true + if (!isDigit(ch)) return false + let k = i - 2 + while (k >= 0 && isDigit(text[k])) k-- + return !(k < i - 2 && k >= 0 && text[k] === '-') +} + +/** + * The pieces a name or e-mail is laid out in where its cell is narrow: the parts of nameWrapParts (the template's + * ), cut again where the browser itself breaks: after a space, after a hyphen (breaksAfterHyphen) and between + * two CJK characters. Joined back, the pieces are the text. + */ +export function nameLinePieces(value: unknown): string[] { + const parts = nameWrapParts(value) + const text = parts.join('') + const cuts = new Set() + let at = 0 + for (const part of parts.slice(0, -1)) cuts.add((at += part.length)) for (let i = 1; i < text.length; i++) { const prev = text[i - 1] const ch = text[i] - const digitRun = isDigit(ch) && isDigit(text[i + 1]) && isDigit(text[i + 2]) - if (ch === '@' || (isLower(prev) && isUpper(ch)) || ((isLower(prev) || isUpper(prev)) && digitRun)) { - parts.push(text.slice(start, i)) - start = i + if ((prev === ' ' && ch !== ' ') || (prev === '-' && breaksAfterHyphen(text, i)) || (isCJK(prev) && isCJK(ch))) cuts.add(i) + } + const pieces: string[] = [] + let from = 0 + for (const cut of [...cuts].sort((a, b) => a - b)) { + pieces.push(text.slice(from, cut)) + from = cut + } + if (text) pieces.push(text.slice(from)) + return pieces +} + +/** A name or e-mail measured for line breaking: its pieces (nameLinePieces) and the width of every run of them. */ +export interface NameRuns { + pieces: string[] + /** run[a][b]: pieces a .. b-1 on one line (a space at its end hangs outside the line, as in the browser) */ + run: number[][] +} + +/** Measures a name or e-mail's pieces and their runs, with `measure` giving a text's width in the cell's font. */ +export function nameRuns(value: unknown, measure: (text: string) => number): NameRuns { + const pieces = nameLinePieces(value) + const run = pieces.map((_, a) => { + const widths = new Array(pieces.length + 1).fill(0) + let line = '' + for (let b = a + 1; b <= pieces.length; b++) widths[b] = measure((line += pieces[b - 1]).trimEnd()) + return widths + }) + return { pieces, run } +} + +/** + * The lines a measured name or e-mail takes at a width, as the browser lays it out (each line ends at the last join + * that still fits), and the widest of them. A piece wider than the width (the column's floor) counts as filling it. + */ +export function nameLines(text: NameRuns, width: number): { lines: number; widest: number } { + const count = text.pieces.length + let lines = 0 + let widest = 0 + for (let a = 0; a < count; ) { + let b = a + 1 + while (b < count && text.run[a][b + 1] <= width) b++ + lines++ + widest = Math.max(widest, Math.min(text.run[a][b], width)) + a = b + } + return { lines, widest } +} + +/** A row's name cell: its name and e-mail measured, and the width of its lines that do not wrap (the #id). */ +export interface NameCell { + texts: NameRuns[] + fixed?: number +} + +/** What an extra line of a name costs, in pixels of blank beside the names (see nameColumnWidth). */ +export const NAME_LINE_COST = 40 + +/** + * The account name column's width for a page of rows (owner 2026-10-01, 「名称这里你留这么大的空白干啥」): the width at + * which the page's names and e-mails leave the least blank beside them, every extra line a name or e-mail takes + * counting as `lineCost` pixels of blank. So the column follows the page's usual names: a page of e-mails takes their + * local part's width (Aelmp67526 | @outlook.com), a page of cindy names their two-line width, and one longer name or + * e-mail takes a third line at its word joins (Nightingale | Giovanna7598 | @hotmail.com) instead of widening the + * column for the whole page. Never narrower than the widest piece (a code, a host name, the #id: nothing breaks inside + * a word; a token wider than `pieceCap` breaks inside rather than set the column) and never wider than the narrowest + * width at which each name and e-mail takes at most two lines (the column does not widen to put names on one line). + * The same at every screen width: the table's spare width goes to the other columns. Returns whole pixels (0 for no + * names) and the widest piece. + */ +export function nameColumnWidth( + cells: NameCell[], + { lineCost = NAME_LINE_COST, pieceCap = Infinity }: { lineCost?: number; pieceCap?: number } = {} +): { width: number; piece: number } { + let piece = 0 + let fixed = 0 + let two = 0 + for (const cell of cells) { + fixed = Math.max(fixed, cell.fixed ?? 0) + for (const text of cell.texts) { + const count = text.pieces.length + let textPiece = 0 + for (let a = 0; a < count; a++) textPiece = Math.max(textPiece, text.run[a][a + 1]) + let textTwo = count ? text.run[0][count] : 0 + for (let s = 1; s < count; s++) textTwo = Math.min(textTwo, Math.max(text.run[0][s], text.run[s][count])) + piece = Math.max(piece, textPiece) + two = Math.max(two, textTwo, textPiece) } } - parts.push(text.slice(start)) - return parts + const from = Math.ceil(Math.max(Math.min(piece, pieceCap), fixed)) + const to = Math.max(from, Math.ceil(two)) + let width = from + let least = Infinity + for (let w = from; w <= to; w++) { + let cost = 0 + for (const cell of cells) { + let ink = cell.fixed ?? 0 + for (const text of cell.texts) { + const laid = nameLines(text, w) + ink = Math.max(ink, laid.widest) + cost += lineCost * laid.lines + } + cost += w - ink + } + if (cost < least) { + least = cost + width = w + } + } + return { width: two > 0 ? width : 0, piece: Math.ceil(piece) } } /** The longest model-id part drawn whole; a usual id (claude-sonnet-4-5-20250929) is far shorter. */ const MODEL_PART_WHOLE_MAX = 32 -const isLetter = (c: string | undefined) => isLower(c) || isUpper(c) - /** * A model id breaks only after a slash (anthropic/ | claude-opus-4-5); each part is drawn whole. An outlier part longer * than 32 characters may also break after a hyphen or underscore that a letter follows, so a version or a date stays diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 47643a172c23..d4585aa5fbc7 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -266,7 +266,7 @@ @select-all-results="handleSelectAllResults" @toggle-schedulable="handleBulkToggleSchedulable" /> -
+
flatThemeActive.value ? nameWrapParts(value) : [value == null ? '' : String(value)] +// Console theme: the name column follows the page's names (styles/console/tables.css, 名称; softWrap.ts +// nameColumnWidth). The page's account names (13px / 500), e-mails (12px / 400, accounts.css) and #ids (Geist Mono 12) +// are measured in the cells' fonts on a canvas, piece by piece, and the column takes the width at which they leave the +// least blank, an extra line counting as 40px of it: one long name or e-mail takes a third line at its word joins +// instead of widening the column for the whole page. Measured from the page's rows, not the rendered ones, so a +// virtualised list keeps one width while it scrolls; the web fonts arriving re-measure. +const nameFontEpoch = ref(0) +const nameRunsCache = new Map() +let nameCanvas: CanvasRenderingContext2D | null | undefined +const remeasureAccountNames = () => { + nameRunsCache.clear() + nameFontEpoch.value++ +} + +const measureAccountName = (ctx: CanvasRenderingContext2D, font: string, text: string): NameRuns => { + const key = `${font}\n${text}` + let runs = nameRunsCache.get(key) + if (!runs) { + ctx.font = font + runs = nameRuns(text, (part) => ctx.measureText(part).width) + nameRunsCache.set(key, runs) + } + return runs +} + +const accountNameColumnStyle = computed | undefined>(() => { + void nameFontEpoch.value + if (!flatThemeActive.value || viewMode.value !== 'table' || typeof document === 'undefined') return undefined + if (nameCanvas === undefined) nameCanvas = document.createElement('canvas').getContext('2d') + const rootStyle = getComputedStyle(document.documentElement) + const fontFamily = (name: string) => rootStyle.getPropertyValue(name).replace(/\s+/g, ' ').trim() + const family = fontFamily('--ui-font') + const mono = fontFamily('--ui-font-mono') + const ctx = nameCanvas + if (!ctx || !family) return undefined + const showId = !hiddenColumns.has('id') && !!mono + const cells: NameCell[] = accounts.value.map((row) => { + const texts: NameRuns[] = [] + if (row.name) texts.push(measureAccountName(ctx, `500 13px ${family}`, String(row.name))) + const email = accountDisplayEmail(row) + if (email) texts.push(measureAccountName(ctx, `400 12px ${family}`, email)) + let fixed = 0 + if (showId) { + ctx.font = `400 12px ${mono}` + fixed = ctx.measureText(`#${row.id}`).width + } + return { texts, fixed } + }) + // a token wider than 16rem breaks inside rather than set the column (the widest piece of the 863 names is a 205px + // host name, integrations.emergentagent.com/) + const { width, piece } = nameColumnWidth(cells, { pieceCap: 256 }) + if (!width) return undefined + // a pixel over the canvas widths, so layout rounding never wraps a line that fits + return { '--account-name-w': `${width + 1}px`, '--account-name-piece': `${piece + 1}px` } +}) + const accountRouteSummary = (account: Account) => { const groups = accountGroupsForRow(account).map(group => group.name).filter(Boolean) const proxy = account.proxy?.name || t('admin.accounts.directConnection') @@ -3006,6 +3062,10 @@ const handleClickOutside = (event: MouseEvent) => { onMounted(async () => { if (route.query.operations === 'history') void accountJobsStore.openDrawer() + if (typeof document !== 'undefined' && document.fonts) { + document.fonts.addEventListener('loadingdone', remeasureAccountNames) + void document.fonts.ready.then(remeasureAccountNames) + } if (typeof window !== 'undefined') { desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery) isDesktopViewport.value = desktopViewportMediaQuery.matches @@ -3049,6 +3109,9 @@ onMounted(async () => { onUnmounted(() => { upstreamBillingRateAbortController?.abort() + if (typeof document !== 'undefined' && document.fonts) { + document.fonts.removeEventListener('loadingdone', remeasureAccountNames) + } if (usageBatchFlushTimer !== null) { clearTimeout(usageBatchFlushTimer) usageBatchFlushTimer = null