Files
Will Miao 965bfd33cd feat(controls): keep model counts readable in large libraries
Counts in the filter panel chips (base models, tags, model types), the All Types
dropdown and the Refresh scope rows were rendered as raw digits, so a library with
10k+ models turned every chip into a five digit pill that dominated the row it sat
in — and the Refresh menu grouped its numbers while the filter panel did not.

One formatter now renders all of them (`formatCount`): exact and locale-grouped
below 10,000, abbreviated to three significant digits above it, and abbreviated
only where the locale's Intl compact form is a real unit and narrower than the
plain number — German keeps 18.475 instead of the rounded 18.500, Russian keeps
18 475 instead of the wider 18,5 тыс.

Their hover text is always present and always exact ({name} — {count} models, and
... recipes on the Recipes page): an affordance that only exists above a threshold
is undiscoverable, and the tooltip is also what reveals a chip label that CSS has
ellipsized.

Chips gain a two-slot layout (the name ellipsizes, the count keeps a tabular
right-aligned slot) so a long tag name can no longer push the count out of the
chip; Refresh rows move from float:right to a flex row, which keeps the counts in
a right-hand column and lets a long root label ellipsize. The All Types group
tooltip moves onto its trigger so it stops leaking onto the menu options.

The two new strings ship as [TODO: Translate] placeholders in the nine locales;
the translation pass is the next commit.
2026-10-18 18:34:16 +08:00

787 lines
20 KiB
CSS

.page-content {
height: calc(100vh - 48px); /* Full height minus header */
margin-top: 48px; /* Push down below header */
width: 100%;
position: relative;
overflow-x: hidden; /* Prevent horizontal scrolling */
overflow-y: scroll; /* Enable vertical scrolling */
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--space-2);
position: relative;
z-index: var(--z-base);
}
/* Sticky controls container */
.controls {
position: sticky;
top: -54px;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
padding: var(--space-1) 0;
box-shadow: var(--shadow-xs);
}
/* Wrapper around the controls bar and breadcrumb nav. With the sticky-controls
setting off it is transparent to layout (display: contents), preserving the
original behavior (only the breadcrumb stays visible). When enabled, the whole
wrapper sticks as one unit so the two bars can never drift apart. */
.sticky-topbar {
display: contents;
}
body.sticky-controls .sticky-topbar {
display: block;
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
box-shadow: var(--shadow-xs);
}
/* Responsive container for larger screens */
@media (min-width: 2150px) {
.container {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.container {
max-width: 2400px;
}
}
.controls-right {
display: flex;
align-items: center;
gap: 8px;
/* Push to the right of the row. Because it is also the flex item that is
allowed to drop to a second row, an auto margin keeps it right-aligned on
either row — no width: 100% / viewport breakpoint needed. */
margin-left: auto;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
viewport width. Viewport-based wrapping wasted space on high-DPI displays
(e.g. a 2560px monitor at 200% scaling reports a ~1280px CSS viewport even
when the window is maximized). */
flex-wrap: wrap;
width: 100%;
}
.action-buttons {
display: flex;
align-items: center;
gap: var(--space-2);
/* Let the group shrink rather than overflow so .controls-right only wraps
when it really has to. */
flex-wrap: wrap;
flex-shrink: 1;
min-width: 0;
}
/* Action button styling */
.control-group {
position: relative;
}
.control-group button {
/* Keeps the toolbar visually even without forcing the row to overflow (the
old 100px floor pushed the total past the container on wide screens). */
min-width: 90px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: var(--border-radius-xs);
padding: 4px 10px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
transition: var(--transition-base);
cursor: pointer;
box-shadow: var(--shadow-xs);
}
.control-group button:hover,
.control-group button:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.control-group button:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.control-group button i {
opacity: 0.8;
transition: opacity 0.2s ease;
}
.control-group button:hover i,
.control-group button:focus-visible i {
opacity: 1;
}
.control-group button:disabled {
cursor: not-allowed;
opacity: 0.6;
pointer-events: none;
}
.control-group button.loading,
.dropdown-toggle.loading {
cursor: wait;
opacity: 0.7;
pointer-events: none;
}
/* Controls */
.control-group button.favorite-filter,
.control-group button.update-filter {
position: relative;
overflow: hidden;
}
.control-group button.favorite-filter.active {
background: var(--lora-accent);
color: var(--text-on-accent);
}
.control-group button.favorite-filter i {
margin-right: 4px;
color: var(--favorite-color);
}
.control-group button.update-filter i {
margin-right: 4px;
color: var(--lora-accent);
}
.control-group button.update-filter.active {
background: var(--lora-accent);
color: var(--text-on-accent);
}
.control-group button.update-filter.active i {
color: var(--text-on-accent);
}
.update-filter-group .dropdown-main.update-filter.active + .dropdown-toggle {
background: var(--lora-accent);
border-color: var(--lora-accent);
color: var(--text-on-accent);
}
.update-filter-group .dropdown-main.update-filter.active + .dropdown-toggle i {
color: inherit;
}
/* Active state for buttons that can be toggled */
.control-group button.active {
background: var(--lora-accent);
color: var(--text-on-accent);
border-color: var(--lora-accent);
}
/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */
.layout-toggle-group {
display: flex;
gap: 0;
}
.layout-toggle-group .layout-toggle-btn {
min-width: 36px;
width: 36px;
padding: 4px 0;
border-radius: 0;
}
.layout-toggle-group .layout-toggle-btn:first-child {
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
border-right: none;
}
.layout-toggle-group .layout-toggle-btn:last-child {
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
}
.layout-toggle-group .layout-toggle-btn:hover,
.layout-toggle-group .layout-toggle-btn:focus-visible {
transform: none;
box-shadow: var(--shadow-xs);
}
/* Keyboard shortcut indicator styling */
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 500;
line-height: 1;
text-transform: uppercase;
border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
color: var(--shortcut-text);
opacity: 0.8;
transition: var(--transition-base);
}
.control-group button:hover .shortcut-key {
opacity: 1;
background-color: var(--shortcut-bg-hover);
border-color: var(--shortcut-border-hover);
}
/* Invert the keycap on active (accent-filled) buttons for contrast.
Must come after the hover rule above so it wins on active+hover. */
.control-group button.active .shortcut-key,
.control-group button.active:hover .shortcut-key {
color: var(--lora-accent);
background: rgba(255, 255, 255, 0.92);
border-color: transparent;
opacity: 1;
}
/* Ensure correct vertical alignment for text+shortcut */
.control-group button span {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Select dropdown styling */
.control-group select {
min-width: 100px;
padding: 4px 26px 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 14px;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
/* Style for optgroups */
.control-group select optgroup {
font-weight: 600;
font-style: normal;
color: var(--text-color);
background-color: var(--card-bg);
}
.control-group select option {
padding: 4px 8px;
background-color: var(--card-bg);
color: var(--text-color);
}
/* Dark theme optgroup styling */
[data-theme="dark"] .control-group select optgroup {
background-color: var(--card-bg);
color: var(--text-color);
}
[data-theme="dark"] .control-group select option {
background-color: var(--card-bg);
color: var(--text-color);
}
.control-group select:hover {
border-color: var(--lora-accent);
background-color: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.control-group select:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
}
/* Disabled sort dropdown — used when VLM custom filter is active */
.control-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.control-group select:disabled:hover {
border-color: var(--border-color);
background-color: var(--bg-color);
transform: none;
box-shadow: none;
}
/* === Sort dropdown — decoupled trigger width ===========================
The native <select> sizes its trigger to the widest <option>, wasting
horizontal space when a short option is selected. This custom trigger
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. The model-type
dropdown (.model-type-*) shares these styles — same trigger/menu look. */
.sort-dropdown-group,
.model-type-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger,
.model-type-trigger {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
max-width: 240px;
padding: 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
.model-type-trigger {
min-width: 90px;
}
.sort-trigger:hover,
.sort-trigger:focus-visible,
.model-type-trigger:hover,
.model-type-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.sort-trigger:active,
.model-type-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label,
.model-type-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret,
.model-type-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret,
.model-type-dropdown-group.active .model-type-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger,
.model-type-dropdown-group.active .model-type-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
.sort-dropdown-group.is-disabled .sort-trigger {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
background: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
/* Dropdown menu — sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu,
.model-type-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
}
/* Optgroup label rendered as a section header */
.sort-dropdown-group .sort-optgroup-label {
padding: 8px 12px 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
cursor: default;
user-select: none;
}
.sort-dropdown-group .sort-optgroup-label:first-child {
padding-top: 4px;
}
/* Option items */
.sort-dropdown-group .sort-option,
.model-type-dropdown-group .model-type-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before,
.model-type-dropdown-group .model-type-option::before {
content: '';
width: 14px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover,
.model-type-dropdown-group .model-type-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected,
.model-type-dropdown-group .model-type-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before,
.model-type-dropdown-group .model-type-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
/* Visually hidden native <select> — kept in the DOM for programmatic access.
High-specificity selector overrides .control-group select { min-width: 100px }. */
.control-group .sort-select-native {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
/* Ensure hidden class works properly */
.hidden {
display: none !important;
}
/* Icon-only button style */
.icon-only {
min-width: unset !important;
width: 32px !important;
padding: 0 !important;
height: 32px !important;
}
/* Back to Top Button */
.back-to-top {
position: fixed;
bottom: 85px;
right: 30px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: var(--transition-slow);
z-index: var(--z-overlay);
box-shadow: var(--shadow-sm);
}
.back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.back-to-top:hover {
background: var(--lora-accent);
color: var(--text-on-accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
/* Prevent text selection in control and header areas */
.control-group button,
.control-group select,
.bulk-operations-panel,
.app-header,
.header-branding,
.app-title,
.main-nav,
.nav-item,
.header-actions button,
.header-controls {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Dropdown Button Styling */
.dropdown-group {
position: relative;
display: flex;
}
.dropdown-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid var(--border-color);
}
.dropdown-toggle {
width: 24px !important;
min-width: unset !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
min-width: max(100%, max-content);
width: max-content;
padding: 5px 0;
margin: 2px 0 0;
font-size: 0.85em;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-xl);
}
.dropdown-group.active .dropdown-menu {
display: block;
}
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
the sticky breadcrumb. */
.controls:has(.dropdown-group.active) {
z-index: var(--z-header);
}
.dropdown-item {
display: block;
padding: 6px 15px;
clear: both;
font-weight: 400;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-item.disabled {
cursor: default;
opacity: 0.6;
pointer-events: none;
}
.dropdown-item:hover {
background-color: oklch(var(--lora-accent) / 0.1);
}
.dropdown-item i {
margin-right: 8px;
width: 16px;
text-align: center;
}
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
one row at common laptop/high-DPI widths. The buttons are allowed to shrink to
their content (min-width: 0) here, which is what reclaims the space the old
100px floor plus a forced wrap used to waste. .controls-right is deliberately
NOT forced onto its own row: it stays inline while it fits and only drops to a
second row (staying right-aligned through its auto margin) when it does not. */
@media (max-width: 1500px) {
.action-buttons {
gap: var(--space-1);
}
.control-group button {
min-width: 0;
padding: 4px 8px;
}
.control-group select {
min-width: 0;
}
}
@media (max-width: 768px) {
.actions {
gap: var(--space-2);
}
.action-buttons {
width: 100%;
}
/* Narrow screens: let the right-hand group wrap below the buttons, still
right-aligned. */
.controls-right {
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-1);
}
.control-group button:hover {
transform: none; /* Disable hover effects on mobile */
}
.control-group select:hover {
transform: none; /* Disable hover effects on mobile */
}
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */
}
.dropdown-menu {
left: auto;
right: 0; /* Align to right on mobile */
}
/* Adjust controls padding on mobile */
.controls {
padding: 10px 0;
}
}
/* Refresh dropdown: per-root scan scope (issue #1108). The list can hold one
entry per configured model root, so it scrolls instead of growing past the
viewport. Offline roots stay clickable (clicking explains the state) and are
only visually de-emphasised — unlike .dropdown-item.disabled, which swallows
the click via pointer-events: none. */
.dropdown-separator {
height: 1px;
margin: 5px 0;
background-color: var(--border-color);
}
.dropdown-section-title {
padding: 4px 15px 2px;
font-size: 0.85em;
font-weight: 600;
color: var(--text-color);
opacity: 0.6;
}
.dropdown-scope-list {
max-height: 45vh;
overflow-y: auto;
}
.scan-root-item.is-offline {
cursor: default;
opacity: 0.6;
}
/* Label and count as two slots instead of label + floated count: the label
column absorbs the row's spare width (so counts still line up on the right)
and ellipsizes rather than pushing the count out of the menu. */
.scan-root-item {
display: flex;
align-items: center;
}
.scan-root-item i {
flex-shrink: 0;
}
.scan-root-label {
flex: 1;
min-width: 0;
margin-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scan-root-count,
.scan-root-offline {
margin-left: 16px;
font-size: 0.9em;
opacity: 0.7;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.scan-root-item.is-offline .scan-root-offline {
opacity: 0.9;
}