mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 10:28:43 -04:00
* feat(system): expose raw detected capability for model meta resolution Model meta gallery entries express hardware fallback through candidate ordering rather than a capability map, so they need the undecorated detected capability string without Capability's default/cpu fallback chain. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(system): drop duplicate capability accessor, cover DetectedCapability ReportedCapability was added with a body identical to the existing DetectedCapability. Keep one accessor and move the specs onto it, since DetectedCapability had no direct coverage of its no-fallback behavior. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): parse IEC binary size suffixes (KiB..PiB) ParseSizeString accepted only SI suffixes, so a "20GiB" floor was rejected outright. Model and VRAM sizes are conventionally quoted in IEC units, and silently reading GiB as GB would understate a floor by about 7%. Purely additive: these inputs previously returned an unknown-suffix error. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add Candidate type for meta model entries Candidate is one option in a meta entry's ordered variant list. It names a concrete gallery entry and declares when that entry suits the host. EffectiveMinVRAM resolves the VRAM floor, letting an authored min_vram win over a nightly-inferred one. An unparseable floor errors instead of being treated as absent: swallowing a typo would turn a constrained candidate into an unconstrained one and select a too-large variant rather than fail loudly. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add hardware-aware model variant resolver Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): allow gallery model entries to declare variant candidates A gallery entry with a non-empty candidates list is a meta entry: it names an ordered list of concrete entries and resolves to the first one the host can satisfy, instead of describing model files directly. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): resolve meta model entries to hardware-appropriate variants at install Meta gallery entries carry an ordered candidate list; at install time the first candidate the host satisfies is resolved and its payload installed under the meta's name, so the model keeps a stable name regardless of which variant backs it. The resolution is recorded in the installed gallery config so a reinstall honors a prior pin and operators can see the backing variant. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): key meta pin recall on the installed name and detach resolved entries Six review findings on the meta-entry install path. Pin recall was keyed on the gallery entry name while applyModel writes the record under the install name (req.Name when supplied), so a meta installed under a custom name with a pin lost that pin on reinstall and was silently re-resolved onto a different variant, possibly swapping its backend. Compute the install name with applyModel's own precedence before the recall. ResolveMetaModel returned a shallow struct copy, so the resolved entry's Overrides aliased the gallery entry's map and the install path's in-place mergo merge wrote the caller's request into the shared catalog. Detach Overrides, ConfigFile, AdditionalFiles, URLs and Tags. Not exploitable today only because this path re-unmarshals the gallery per call, which is a property nobody should have to rely on. Also: overlay the meta's name onto the persisted config for meta installs so the gallery file no longer records the variant's name; move the pinned-VRAM warning below the variant validation so a pin naming a nonexistent entry does not warn about VRAM before failing for an unrelated reason; and stop seeding config.URLs in the config_file branch, which duplicated every declared URL. Add seven network-free specs driving InstallModelFromGallery with a meta entry: variant payload wins over the meta's legacy url fallback, the resolution record round-trips to disk, a pin is recorded and honored on reinstall including under a custom install name, and the resolved entry does not alias the gallery's maps. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): deep-copy meta overrides and make two specs functional ResolveMetaModel detached the resolved entry's Overrides and ConfigFile with maps.Clone, which only copies the top level. Gallery overrides are nested in practice (parameters.model is near-universal) and the install path merges the caller's request with mergo.WithOverride, which recurses into nested maps and overwrites them in place, so the gallery entry's own inner maps were still reachable and still got rewritten by the last caller to install. Copy both maps all the way down instead, recursing through the container shapes a YAML decoder produces. ConfigFile is not mutated on the install path today, but it carries the same kind of nested payload and leaving it shallowly cloned would invite the bug back. Also fix two specs that passed whether or not their target fix was present: - "does not write the caller's overrides back into the gallery entry" re-read the catalog from disk, which re-unmarshals fresh structs and so cannot observe in-memory aliasing. It now asserts against the in-memory gallery entry and drives the real mergo merge. - "round-trips the resolution record to disk under the meta's name" asserted a name that is already correct in the config_file branch. It now drives the url branch via a file:// fixture, where the meta-name overlay actually applies. Both were verified red by reverting their fix. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(gallery): lint meta model entry invariants in index.yaml Adds Ginkgo specs that parse the shipped gallery/index.yaml and enforce the invariants that keep meta entries safe: a legacy url fallback equal to the final candidate's url, references only to existing non-meta entries, a min_vram floor on every candidate but the last-resort one, a capability drawn only from the vocabulary the system can report, and descending VRAM floors within a capability group. The capability check is the only compensating control for a typo there. Candidate matching is a case-sensitive exact comparison against SystemState.DetectedCapability(), so an unknown value never matches and falls through silently instead of erroring. The vocabulary therefore mirrors the raw return set of getSystemCapabilities(), which notably excludes "cpu": that is a fallback key inside Capability(capMap) on the meta backend path, never a reported capability. A CPU-only host reports "default". These pass vacuously until the pilot meta entry lands; the guard is intentionally in place before the thing it guards. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(gallery): close coverage gaps in the meta entry lint The ordering invariant grouped candidates by capability and asserted floors descend within a group. A candidate with an EMPTY capability matches every host, so it does not belong in its own group: it dominates every later candidate whose floor is at or above its own, across capability groups. Track a running minimum floor over the unconditional candidates instead, which subsumes the old same-group check for the empty capability. Every spec skipped non-meta entries, so with zero meta entries in the index all five bodies were no-ops. Aligning GalleryModel.IsMeta() with GalleryBackend.IsMeta(), whose semantics are deliberately opposite, would have made all of them pass while checking nothing. Extract each invariant into a helper over a slice of entries returning the violations it finds, and cover those helpers with synthetic fixtures so the logic stays tested at zero meta entries. The index-driven specs are now a thin application of already proven logic. Also assert the index parses non-empty, report every violation in one run rather than aborting on the first, and parse the index once for the suite. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(gallery): add nightly denormalization of meta model candidates Fills the read-only backend, quantization and inferred_min_vram fields on meta gallery candidates and opens a PR, modeled on the existing checksum_checker job. Computing these needs network access, so it happens nightly rather than at install time. An authored min_vram is never modified: a human who measured a real load knows more than a pre-download estimate does. The index is rewritten via yaml.Node rather than a document round-trip. A full round-trip reflows all ~26k lines of gallery/index.yaml, which would bury the computed values and make the nightly PR unreviewable. The rewrite touches only the three derived keys, so authored styling survives and a run that computes nothing leaves the file untouched. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ci): keep the gallery denormalize diff reviewable and self-healing The nightly denormalization job edits YAML nodes instead of round-tripping structs so its PR stays small enough for a human to review, but the write path undid that: yaml.Marshal re-encoded the node tree at yaml.v3's default 4-space indent and dropped the leading document marker, reflowing roughly 6000 lines around the handful of real changes. Encode through yaml.NewEncoder at the index's authored 2-space indent and restore the header. A write that changes three fields now changes three lines. Stale inferred_min_vram values were also never cleared. Both skip paths (an authored min_vram is present, or the candidate is the last resort) returned before touching the field, so a candidate that gained a floor or became the last resort after a reorder kept an inferred value that EffectiveMinVRAM reported as a real constraint, failing the meta lint with no way for the job to self-heal. Clear the field before both skips. The workflow discarded a whole night's work on any single failure: the program exits 1 when a candidate cannot be estimated, which aborted the job before the PR step, so one unreachable candidate blocked every other refresh indefinitely. Capture the status, open the PR with what was computed, mark the PR body as partial, and fail the run afterwards so the problem still surfaces. Also preserve the index's existing file mode instead of forcing 0644, and drop the redundant //go:build ignore tag, since Go already skips dot directories and the sibling modelslist.go carries no tag. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add nanbeige4.1-3b meta entry with hardware-resolved variants Adds the first real meta entry to the gallery index. It resolves to the Q8_0 build on hosts with at least 6GiB of VRAM and to the Q4_K_M build everywhere else, installing either payload under the stable name nanbeige4.1-3b. The entry carries a url equal to its final candidate's url. LocalAI releases that predate candidates support parse the index non-strictly and drop the key silently, so without that url they would list the entry and install nothing. A regression spec parses the index the way those releases do and asserts every meta entry stays installable for them. Also teaches core/schema/gallery-model.schema.json about candidates. The schema sets additionalProperties: false at the top level, so an author following CONTRIBUTING.md and adding the yaml-language-server comment would otherwise get a validation error on this entry. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): make candidate entries complete, installable entries Reworks hardware-resolved gallery variants after a design pivot. There is no longer a separate "meta" entry kind. A gallery entry is a normal, complete entry that may additionally carry candidates:, a list of hardware-gated upgrades over itself, and the entry is itself the last-resort candidate. The previous design relied on a bare url: as the fallback for LocalAI releases that predate candidates support. That fallback is empty in practice: none of the 80 gallery/*.yaml files carry a top-level files:, and 1216 of 1281 index entries carry their payload in the index entry itself, so a url alone yields a config template with nothing to download. Since every released LocalAI reads gallery/index.yaml live from master, merging a payload-less entry would have shown every existing user a model that installs to a broken state. Making the entry its own base candidate removes the problem at the root: old clients drop the candidates key and install the entry exactly as they do today. Resolution order is now explicit pin, then capability plus VRAM over the declared upgrades, then the entry itself. The entry ALWAYS installs: when its own min_vram or capability is unmet the installer warns and installs it anyway, because there is nothing below it and refusing would make the gallery behave worse the newer the client is. A pin naming the entry's own name is valid and is how an operator declines an upgrade. IsMeta() becomes HasCandidates(), ResolveMetaModel becomes ResolveVariant, and the persisted meta_name record key becomes entry_name. GalleryBackend.IsMeta() is a separate concept and is untouched. The lint drops the three rules the pivot makes wrong (url equality with the final candidate, no inline payload, unconstrained final candidate) and gains one: the entry's own floor must sit strictly below every candidate's, since a base that outranks a candidate makes that candidate unreachable. The pilot entry is now the existing nanbeige4.1-3b-q4, which gains a 2GiB floor of its own and a single 6GiB upgrade to nanbeige4.1-3b-q8, replacing the separate nanbeige4.1-3b entry added ind0d441bb4. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): select model variants by hardware fit, not authored order Gallery entries could already carry a list of alternatives, but selection was an authored, ordered, first-match policy: every candidate declared a `capability` string and the VRAM floors had to descend in a hand-tuned order. That pushed hardware knowledge onto whoever edits the gallery and made ordering load-bearing, so a reordered list silently changed what users installed. None of it was necessary. SystemState.IsBackendCompatible already derives hardware support from a backend name alone: it knows MLX and metal are Darwin-only, CUDA is NVIDIA-only, ROCm AMD-only, SYCL Intel-only. Selection can read that instead of asking authors to restate it. Authoring is now just a list of names: - name: qwen3.6-27b min_memory: 4GiB variants: - model: qwen3.6-27b-mlx-8bit - model: qwen3.6-27b-gguf-q8 min_memory: 28GiB and all the intelligence moved into the selector. Given a host it drops the variants whose backend cannot run here, drops those whose known memory requirement exceeds what the host has, and takes the LARGEST of what is left, because a bigger footprint is a higher quality quantization of the same model. A variant of unknown size is kept, since nothing proves it does not fit, but it ranks last so a proven fit always beats a guess. An explicit pin still wins outright, and if nothing survives the entry installs its own payload: the base always installs, this never refuses. Available memory is VRAM when a GPU was detected and system RAM otherwise, read through xsysinfo so a cgroup limit is honored and a container gets its own limit rather than the node's RAM. Capability disappears entirely, from the types, the schema and the lint. VRAM and RAM collapse into one `min_memory`, because a model's footprint is roughly the same wherever it lives and one figure is compared against whichever applies. The lint rules about ordering, the capability vocabulary and floor relationships are deleted with the hazards they described; what remains is that every variant names an entry that exists and does not itself declare variants, plus that any memory figure actually parses. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * gallery: size model variants with a live probe, drop the nightly denormalizer Selection needs each variant's size to decide whether it fits and to rank largest-first. That figure was written into the index by a nightly job, which made the gallery carry a derived value that could drift from the entry it was derived from. Derive it at install time instead. pkg/vram already sizes a model without downloading it, and the gallery UI already uses it: a remote GGUF header range-fetch, then an HTTP HEAD for the content length, then any declared size:. It caches its results, so reuse it rather than writing a second probing path. A probe failure must never fail an install, so an unprobeable variant is treated as unknown: it survives the memory filter, because nothing proves it does not fit, and it ranks last, so a known-good fit always beats a guess. If every probe fails, selection still terminates on the base entry. The probe is injected through ResolveEnv rather than called directly, for the same reason the backend compatibility check is: specs pin an exact size, or an exact failure, without reaching the network. With that in place three things are dead weight and go: - The nightly job and the fields it populated. Variant.Backend was redundant because the backend is resolved live from the referenced entry during selection, and Quantization was display-only that nothing read. - min_memory on the base entry. The base always installs and its floor could only warn, so it could not change any outcome. - The lint rules and schema entries for both. min_memory on individual variants stays, as the override for when the probed size is wrong. An authored figure now suppresses the probe entirely rather than merely outranking it, so it costs no round trip. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): expose model variants for selection over API, CLI and MCP A gallery entry may carry `variants:`, alternative builds of the same model. Selection already worked at install time, but nothing could see what an entry offered or ask for a specific build, so the feature was undrivable. Listing: `GET /api/models` now reports `variants` and `auto_variant` for the entries that declare variants. Each variant carries its resolved backend, its measured size and whether it fits this host. `auto_variant` is what installing without a choice would pick right now. The new gallery.DescribeVariants runs the same variantOptions + SelectVariant pass the installer runs, so the reported default cannot drift from what installing actually does, and HostResolveEnv is extracted so both derive the host and share pkg/vram's probe cache from one place. Performance: an entry that declares no variants returns early without touching the probe, so the ~1280 ordinary entries cost exactly what they cost before. Selection: `variant` is accepted on POST /models/apply, as a query param on POST /api/models/install/:id, on the gallery apply file/string request, as `local-ai models install --variant`, and as a parameter on the install_model MCP tool (both the httpapi and inproc clients). Empty means auto-select. An unknown variant name now fails the install naming what was requested. This closes a real hole: an entry declaring no variants short-circuits before selection runs, so a requested variant was previously dropped silently and the install reported success. startup.InstallModels ends in a variadic model list, so install options could not be appended to it; InstallModelsWithOptions is added alongside and InstallModels delegates to it. No caller signature changed. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): drop the redundant variant min_memory field Variant.MinMemory was an authored override for when the live probe misreads a variant's footprint. It duplicated an existing field: probeEntryMemory already passes the entry's declared size: into EstimateModelMultiContext, whose cascade prefers that declared size over its own guesswork. Correcting size: on the referenced entry fixes the figure for every consumer rather than only for variant selection, so min_memory shadowed the right answer. A variant is now nothing but a name. Its effective size is exactly the probe result, and an unknown stays unknown: it survives the filter and ranks last. EffectiveMemory loses its error return along with the field. The authored string was the only thing that could fail to parse, so the error had no remaining source and was propagating dead nil-checks through SelectVariant, DescribeVariants and the pin warning. Selection behaviour is unchanged. The specs covering probe-derived sizing, ranking, filtering, the unknown-size path, pin recall, entry/variant metadata split and deep-copy isolation all survive; the three install specs that needed a definite size now declare it through the referenced entry's own size:, which exercises the documented escape hatch directly. gallery/index.yaml is untouched: no entry ever carried the key. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): rank the entry's own build against its variants Variant selection pulled the declaring entry's own payload, the base, out of the candidate set and consulted it only once every declared variant had been rejected. Two real failures followed. A variant whose size the probe cannot determine deliberately survives the memory filter, because nothing proves it does not fit. As the only survivor it then won outright on any host, however small: a 2GiB machine installed an unmeasured variant in preference to the 4GiB build the entry itself ships, with no warning. 241 of the 1280 current index entries carry no files and no size, which is exactly that shape. "Largest wins" also broke whenever the base was the largest. An author writing a Q8 entry that offers a Q4 downgrade for small hosts, a natural shape that nothing in the lint, schema or docs discourages, had the Q4 installed on every large host instead. Make the base an ordinary participant. It is still exempt from both filters, so selection always terminates on something installable, but it is now ranked against the variants: a proven fit first and largest, then the base, then any variant whose size nothing could measure. Both failures disappear together. The base is probed for its size accordingly, which it was not before, because an unsized base would lose every contest to an unmeasurable variant. FellBackToBase is kept but narrowed to "no declared variant survived", rather than "the base was chosen", since the base now also wins on merit and that is not worth warning about. A recalled variant pin also became a permanent install failure. A pin the caller supplies on this request must stay fatal, but one recalled from ._gallery_<name>.yaml can be invalidated by any later gallery edit, and failing on it turned one rename into a model that could never be reinstalled or upgraded again short of deleting a dotfile the user has never heard of. A stale recalled pin is now dropped with a warning naming it, and selection runs as if it had never been recorded. Also drop the last textual reference to two abandoned designs from the DetectedCapability comment, correct the documented variants JSON example, which showed a memory_bytes of 0 that omitempty makes impossible, and remove an em dash from the install skill. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): budget variant memory from RAM when a GPU reports no VRAM Variant selection read its memory budget from VRAM whenever a GPU capability was detected, and from system RAM only when none was. Apple Silicon satisfies the first branch and fails the premise: arm64 macs report the metal capability unconditionally, without probing anything, while TotalAvailableVRAM has no discrete VRAM pool to find and returns zero. The budget therefore came out as zero on every Mac. Zero drops every variant carrying a known size, so the base build was installed on all of them however much memory the machine had. The feature was inert on the platform, and silently: falling back to the base is a legitimate outcome, so nothing looked wrong. Take VRAM only when it is actually a number, and fall back to RAM otherwise. On a unified-memory host RAM is not an approximation of the budget, it is the budget, since the GPU shares it. A discrete GPU whose VRAM could not be read also lands on RAM, which overstates what the card holds but understates nothing the host has; the previous zero understated both. An unreadable RAM figure still yields zero and still installs the base, so a genuinely unknown host is not talked into a larger download. This is what turned tests-apple red: "installs a fitting variant's payload under the entry's own name" asserts on selection, and the runner resolved to the base because its budget was zero. The added specs pin the branch directly rather than relying on a macOS runner to notice again. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): add a model variant picker to the models gallery PR #10943 shipped the server side: a gallery entry may declare `variants:`, `GET /api/models` attaches `variants` and `auto_variant` to declaring entries, and `POST /api/models/install/:id` accepts a `variant` query parameter. Nothing in the UI consumed any of it, so the feature was not reachable from the browser. This wires it up. modelsApi.install takes an optional second argument and appends an encoded `?variant=` only when one is given, so every existing call site keeps sending exactly the request it sent before. On the models table, an entry that declares variants gets a split button. The primary Install still installs the auto-selected build, because auto is the default and the point of the feature; the chevron opens a menu for a deliberate override. It follows the Backends.jsx precedent: one shared Popover re-anchored per row, rendering .action-menu items, which brings Escape, outside-click and focus return along with it. An entry that declares no variants renders exactly as it did before. A variant that does not fit is dimmed but stays selectable, since the server honors an explicit choice with a warning rather than refusing it. memory_bytes is omitempty on the wire, so an absent key means the size is unknown and never zero. A single helper guards both the menu and the detail row, because formatBytes would otherwise render a falsy value as "0 B", which reads as "needs nothing". The expanded detail row gains a Variants section listing each build's backend, size, whether it fits, which is the entry's own build, and which one auto-selection would pick, built from the existing DetailRow helper and .badge classes. Eight Playwright specs cover the picker, including that plain Install sends no variant parameter and that choosing one sends it. One pre-existing assertion was scoped with .first(): the Variants section legitimately adds more llama-cpp badges to the detail row, which tripped strict mode. UI line coverage 49.42% -> 49.36% against a 40.0 baseline and 0.8pp tolerance; branch coverage rose 72.04% -> 72.66%. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): describe model variants from a companion endpoint Variant description probes each referenced entry's weight files over the network: an HTTP HEAD plus a ranged GET, serial, five seconds per probe with no aggregate deadline. Running it inline in GET /api/models made one listing cost (entries x variants) round trips. The Manage page fetches with items=9999, so at 200 declaring entries that is ~1000 serial probes, minutes of a blocked handler and gigabytes of range traffic for a single page load. Only one entry declares variants today, but the feature exists so that many will. Follow the precedent already set for VRAM estimates. The listing now reports only has_variants, a length check on loaded metadata that touches nothing, and GET /api/models/variants/:id returns the description for one entry, mirroring estimate/:id in route shape, auth and error handling. DescribeVariants itself is unchanged; only its caller moved. The picker fetches lazily at the two points where a user asks to see variants, opening the split-button menu and expanding the detail row, and caches per entry for the page session. An entry declaring no variants issues no request at all. A spec counts real HTTP hits on the weight files, so it goes red if description becomes reachable from the listing path again through any caller. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): filter the model gallery to entries that declare variants The gallery is heading towards showing parent entries and hiding the individual builds they reference, so a user sees one row per model rather than six quantizations of it. Adoption is a single entry today, so defaulting to that would leave a one-row gallery. This ships the migration-phase inverse instead: the default is untouched, and a toggle narrows the list to only the entries that declare variants. It previews the end state and changes nothing until someone asks for it. The filter is server-side, next to term/tag/backend/capability and above the pagination arithmetic. The listing paginates at 9 items, so narrowing on the client would leave totalPages and availableModels describing the unfiltered set and hand the user empty pages. It selects on HasVariants(), which reads already-loaded metadata, so it issues no variant probes. The parameter is named has_variants after the listing field it selects on, and is compared against "true" like the other boolean query params (all_users, save_checkpoint), so has_variants=false reads as absent. With it omitted the response is byte-for-byte what it was before. The control is the shared Toggle component, matching the fitsFilter toggle already on this page: same wrapper class, same icon and label shape, same localStorage persistence. Unlike fitsFilter it resets to page 1 on change, which a server-side filter has to do. Stacking the toggle with a tag or backend filter easily yields nothing while one entry declares variants, so the empty state now names the variants filter as the cause rather than leaving a user to conclude the gallery is broken. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): render gallery model descriptions as Markdown Gallery descriptions are Markdown, but the React UI dumped them raw, so a model whose description opens with an ATX heading showed a literal "# Qwen3.6-27B [](https://chat.qwen.ai)" in the list. Full-description areas now render through renderMarkdown (marked + DOMPurify), matching how Backends.jsx and the Manage detail panels already handle the same content: - Models.jsx expanded detail row - VoiceLibrary.jsx voice detail header The truncated one-line previews must not render block Markdown: a leading "#" would become an <h1> and wreck the row height and rhythm. They get a new stripMarkdown() helper instead, which reduces Markdown to a single line of readable plain text. It is used for the cell text and for the title tooltip, since a tooltip full of "[](url)" is no better than a cell full of it: - Models.jsx gallery table description cell - Manage.jsx model and backend resource-row descriptions stripMarkdown walks marked's lexer output rather than running regexes over the source, so what it strips is by construction what renderMarkdown would have rendered, and it needs no new dependency. Output lands in JSX text nodes, so React escapes it; no new dangerouslySetInnerHTML beyond the two full-description sites, both of which run DOMPurify. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): strip Markdown from the backends table description cell Commitb35d630cffixed this for gallery models but left the Backends admin page with the same asymmetry: its detail panel renders the description through renderMarkdown, while the collapsed table row dumped the raw gallery string into both the cell body and the title tooltip. That is user-visible. 40 of the 949 entries in backend/index.yaml carry Markdown - insightface uses inline code backticks, others use lists and links - and backend descriptions also contain embedded newlines, so the one-line cell showed literal syntax. The cell now runs stripMarkdown over the description once and uses the result for the text and the title, matching Models.jsx and the ResourceRowDesc component in Manage.jsx. The '-' placeholder is preserved, and now also fires when a description reduces to nothing after stripping. The detail panel is untouched and no new dangerouslySetInnerHTML is introduced: stripMarkdown output lands in a JSX text node, so React escapes it. Three Playwright specs cover it: a description with a heading, inline code and a link renders as clean text with no literal syntax and no block element in the cell, the title tooltip carries the same stripped text, and a backend without a description still shows the placeholder. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ui(models): polish the variant detail view and scope rendered Markdown The gallery detail pane rendered every field through the same two-column label/value row, including the description. Multi-paragraph prose in a value cell ran eight rows tall at the top of the pane on a ~1200px measure, breaking the grid's rhythm exactly where the eye enters. Move it into its own full-width block above the table, capped at a 68ch measure, keeping the label. Rendered Markdown had no scoped typography anywhere in the app, so a description opening with `#` inherited the browser default 2em inside a 13px surface while a `##` further down was indistinguishable from body text. Add a reusable .markdown-body block mapping h1-h6, paragraphs, lists, links, code, blockquotes, images and tables onto the existing type scale, and apply it to every renderMarkdown() consumer: the models detail, the backends detail, both Manage details and the voice library detail. Rebalance the variants list so the name leads. Backend and size drop from badge/secondary weight to muted metadata; the FITS badge goes entirely, since it was true of nearly every row and so said nothing, while the variant that does not fit keeps a warning badge and a dimmed name. AUTO-SELECTED stays marked because it answers what a plain Install produces. Rows share the parent's grid tracks via subgrid so name, backend, size and status line up down the list instead of raggedly following name length. Finally, make each variant row actionable. It looked like a list of choices but was inert text, with per-variant install hidden behind the split-button chevron elsewhere; each row is now a button onto the existing handleInstall(modelId, variant) path, with hover, keyboard focus and a disabled state while an install is in flight. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): collapse the listing to one row per model The listing supported has_variants=true, which narrowed to entries that DECLARE variants. With adoption at three entries that showed three rows, which is useless; it was always a placeholder. Replace it with the view that is actually useful: the deduplicated gallery. Show every entry installable in its own right and nothing twice, which means the parents plus every entry nobody references, and hide only the builds another entry already offers as a variant, since those are reachable through their parent. The parameter is renamed to collapse_variants accordingly: the filter is no longer a predicate on a row's own metadata but a view over the whole gallery. Default stays off, so the response with the parameter absent is unchanged. VariantReferencedIDs never reports an entry that declares variants of its own, so parents are always visible. That guarantees every hidden entry has a visible entry offering it, and no chain can strand a row. Variant resolution already refuses to install such a reference, but the listing has to stay coherent in the presence of a gallery that has one rather than silently swallowing entries. Self-references and dangling references hide nothing. The referenced set is computed over the whole gallery rather than over what the other filters left, so an entry is hidden because a parent offers it and never because of what the user searched for. The pass is over metadata already in memory: it resolves nothing over the network and triggers no variant description or size probe, so the listing's zero-probe contract still holds. The UI toggle keeps its behaviour (persistence, page reset, clear filters) and becomes "One row per model", which says what the user gets. Its localStorage key moves too, since the stored value meant a different filter. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): show the collapsed model listing by default The gallery listing is what a user reaches for to answer "what can I install". Answering that with several rows for the same model, one per build, makes the reader do the deduplication the collapsed view already does, so the collapsed view is the one to land on. The UI now asks for collapse_variants=true unless the toggle says otherwise. The server default is deliberately untouched: a request with the parameter absent still returns the full listing, because other API clients depend on that response and collapsing it under them would be a breaking change. Opting out omits the parameter rather than sending false, so it asks for exactly the listing everyone else gets. The stored preference changes vocabulary from '1'/'0' to 'on'/'off'. The previous build wrote it from an effect that runs on mount, so a stored '0' recorded that the page had been opened rather than that anyone chose the expanded view, and honouring it would pin every earlier visitor to a default they never picked. Only the new vocabulary counts as a choice; a legacy '1' meant the collapsed view and is what the new default gives anyway, so no earlier deliberate choice is lost. Collapsing being the default also changes what the empty state may say about it. An opted-into filter can be named as the cause of an empty result; a default cannot, so the filters keep the top line and the collapsed view drops to a hint below it, shown only once filters are narrowing the set. For the same reason "Clear filters" now restores the collapsed default instead of switching it off, and the toggle alone no longer counts as a filter worth offering to clear. The label stays "One row per model": it describes the view the user is looking at rather than an action, so it reads the same whether it is opted into or out of. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * gallery: group alternative builds of the same weights under variants Sweep the gallery for entries that are alternative builds of the same weights (different quantization, precision, or runtime format) and declare them as variants of a single parent row, so the listing offers one row per model instead of one row per quantization and the installer picks the largest build that this host can actually run. 41 families over 95 entries, turning 54 entries into variants. The parent is the bare-named entry wherever one exists, so nothing changes about what any existing entry installs. Ranking already selects the largest fitting build regardless of which entry is nominally the parent, so the parent only decides the pathological case where nothing fits. For the ten families that have no bare-named entry, the smallest build is the parent, since that is the one that has to install when nothing fits. Grouping was verified against the actual model filenames rather than the entry names alone. Different parameter sizes, languages, finetunes, and products that merely share a name prefix are left as separate rows: the qwen3.6 APEX and pi-tune finetunes, the DFlash and MTP speculative-decoding pairings, English-only versus multilingual Whisper, the QAT versus non-QAT Gemma 4 weights, and the abliterated FLUX build are all distinct models. Six parents define YAML anchors that other entries pull in with a merge key, which would have handed their variants to every merging child. For the two depth-anything anchors that would have made fourteen unrelated entries advertise the base model's builds as their own. All 26 merging children therefore carry an explicit empty variants list, which overrides the merged key and is equivalent to the key being absent. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): rank model variants by host backend preference Variant auto-selection filtered candidates by whether their backend can run on the host, then ranked the survivors by size alone. The backend never influenced the choice beyond that gate, so a Mac offered both an MLX build and a llama.cpp build kept neither filtered and installed whichever was larger, leaving the native accelerated runtime unused. The same held for CUDA against CPU on NVIDIA and ROCm against Vulkan on AMD. Rank by the host's backend preference between the fit tier and size: fit stays a filter, preference decides among the builds the host can equally hold, and size still separates builds on equally preferred runtimes. The preference data stays in one declarative table in pkg/system, now read by a prefix lookup instead of a switch, so adding a capability or reordering one host's runtimes is a one-line edit and the gallery's ranking code carries no per-backend branching. MLX joins the metal rule ahead of metal itself, which is inert for the existing alias-resolution consumer because no alias group holds a candidate named for mlx. An unrecognised backend, an unrecognised capability and an absent preference list all collapse to the previous size-only ordering rather than erroring or dropping candidates. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): rank variants by engine name, not backend build tag Variant auto-selection ranked candidates with SystemState.BackendPreferenceTokens, but that function and the variant ranker speak different vocabularies. BackendPreferenceTokens returns BUILD TAGS ("cuda", "rocm", "sycl", "vulkan", "metal", "cpu"). It exists to match installed backend build directory names like "llama-cpp-cuda-12" during alias resolution in ListSystemBackends. Variant ranking instead matches a gallery entry's `backend:` value, which is an ENGINE NAME: "llama-cpp", "vllm", "vllm-omni", "sglang", "mlx" and the rest. No engine name in gallery/index.yaml contains "cuda", "rocm", "sycl" or "vulkan". preferenceRank matches by substring, so on an NVIDIA host the tokens [cuda, vulkan, cpu] matched neither "llama-cpp" nor "vllm", every candidate scored identically and size alone decided. The NVIDIA, AMD, Intel, darwin-x86 and vulkan rules were all inert. Only metal appeared to work, and only because the token "mlx" happens to equal an engine name. The mismatch does not error, it silently deletes the feature. Separate the two vocabularies. backendBuildTagPreferenceRules keeps the build tags and its original output for every capability, including metal, whose "mlx" token is removed again; its alias-resolution consumer is byte-identical to before. engineNamePreferenceRules is new, holds engine names, and is read by the new EnginePreferenceTokens, which HostResolveEnv wires into the renamed ResolveEnv.EnginePreference. Both tables sit adjacent under one block comment naming each vocabulary and each consumer, and share one lookup helper so their semantics cannot drift. On NVIDIA the order is vLLM, then SGLang, then llama-cpp: vLLM is the throughput engine and a model published with a vLLM build is published that way because that build is the one worth running. AMD and Intel get the same order, since rocm and intel builds of both serving engines ship. Metal prefers mlx over llama-cpp. Vulkan prefers llama-cpp, the only LLM engine with a Vulkan build. darwin-x86 and unknown capabilities are deliberately absent rather than guessed at, degrading to the size-only ordering that predates preference. preferenceRank stays generic and names no engine and no capability, so adding a runtime remains a one-line table edit. Specs pin the NVIDIA and metal rules through the live table and the real HostResolveEnv wiring, so emptying the engine table or wiring the build tag source back in both go red. A regression table asserts BackendPreferenceTokens' original output per capability, and mirrored locks assert neither table carries the other's vocabulary. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: record that variant selection ranks by engine before size A gallery entry can now declare variants, and selection ranks the builds a host can run by engine preference before size. Nothing told a contributor adding a backend that engineNamePreferenceRules exists, so a new engine would silently rank below every known one and lose to whatever build happened to be larger on hosts where it should have won. Document the step where a backend is added, warn against the sibling backendBuildTagPreferenceRules table (build tags, not engine names: the wrong table matches nothing, scores every candidate equally and disables the preference without erroring), and index it from AGENTS.md. Fix the authoring and user docs, which still claimed the largest surviving build wins. An author grouping builds under one entry has to be able to predict what a user gets, and size alone no longer decides it. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(cli,mcp): describe variant auto-selection as preference before size The CLI flag help and the install_model tool schema both still said auto-selection takes the largest build that runs. Ranking now puts engine preference ahead of size, so on NVIDIA a vLLM build wins over a larger llama.cpp one. An assistant reading the old schema would tell users the wrong thing. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): prefer llama.cpp over GPU serving engines on hosts with no GPU engineNamePreferenceRules had no row for the "default" capability, which getSystemCapabilities() returns both when no GPU is detected and when a GPU is present but under the 4 GiB VRAM floor. A missing row yields an empty preference list, which preferenceRank reads as "score everything equally", collapsing variant selection to size alone. That would be harmless if the hardware filter dropped GPU serving engines on such a host, but it does not. IsBackendCompatible derives support from the engine NAME, and "vllm" and "sglang" contain none of the darwin, cuda, rocm or sycl tokens it keys on, so they fall through to its closing "return true". A vLLM variant therefore survives on a CPU-only box and wins whenever its build is the larger of the two on offer: the machine installs vLLM in preference to llama.cpp. darwin-x86 had the identical hole. It was documented as a deliberate omission because nothing accelerates on an Intel Mac, which is true about acceleration and wrong about consequence: with every engine tied, download size decides. Add rows for both putting llama-cpp first. The GPU engines are enumerated behind it rather than left unmatched: an unmatched engine already ranks below every listed one, so llama.cpp would win either way, but unmatched engines also tie with each other and let size decide among them. Naming them fixes that order. MLX is left off the darwin-x86 row on purpose so it ranks last, since IsBackendCompatible admits darwin-tokened engines on that capability even though MLX needs Apple silicon. Preference orders survivors and never filters, so a model published only as a vLLM build is still installed on a host with no GPU; there is a spec for it. Surveyed every other value getSystemCapabilities() can return. nvidia, amd, intel and vulkan have rows; the l4t and cuda-refined values reach the nvidia row by prefix; "apple" and "" cannot reach the vendor fallthrough because the darwin and no-GPU branches return earlier. default and darwin-x86 were the only live holes. BackendPreferenceTokens and its build-tag table are untouched, and preferenceRank stays generic, naming no engine and no capability. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * gallery: prefer speculative-decoding builds when they fit Rank serving features between engine preference and size, so a host that can hold a DFlash or MTP build of a model's weights installs it instead of the plain build. Both answer faster for the same output, so whenever one survives the filters there is no reason to take the plain build. Precedence is now fit, then engine, then serving feature, then size. Engine outranks the feature deliberately: a serving feature makes the right engine faster, it does not make a wrong engine right, so a plain vLLM build still beats a DFlash llama.cpp build on NVIDIA. Fit outranks both, and a drafter pairing is strictly larger than the plain build, so the existing size filter drops it on a host too small for it before this axis is consulted. The order lives in a third preference table in pkg/system, alongside the build tag and engine name tables. It is the odd one of the three: not keyed by capability, because no hardware prefers a plain build over an equivalent faster one, and matched against whole segments of a gallery ENTRY NAME rather than as a substring of a backend value. Nothing on a gallery entry declares a serving feature, and tags are not a usable substitute: gemma-4-e2b-it:sglang-mtp carries an mtp tag while ornith-1.0-9b-mtp and qwen3.6-27b-nvfp4-mtp carry none. Entry names are author-supplied free text, unlike the closed engine vocabulary, so a short marker can turn up inside an unrelated word and whole segment matching is what keeps smtp-assistant from ranking as an MTP build. The block comment over the tables now documents all three together and states what each is matched against; the ranking code names no feature, so adding one stays a one-line edit to the table.29c49203brejected these entries as serving configurations rather than alternative builds of the same weights. The definition is now "alternative ways to serve the same model", which includes them, so regroup 14 entries under 12 parents. Judged by the files each entry points at: the qwen3.6, qwen3.5, qwen3 and deepseek pairings are the base GGUF plus a drafter, the gemma-4 QAT MTP entries are the same QAT weights at a different quantization plus an MTP drafter, and the two sglang MTP entries describe themselves as the same model served with speculative decoding. Left separate: qwen3.6-27b-mtp-pi-tune, a finetune with its own weights, and every entry whose base model LocalAI does not ship as its own row, which is the whole Qwopus line plus gemmable-4-12b-mtp, mimo-7b-mtp:sglang and qwen3.5-4b-dflash. None of the twelve parents defines a YAML anchor, so no variants key can leak through a merge key and no empty override was needed this time. The index was edited by line insertion only. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test: check env restore errors in capability and variant specs errcheck flagged ten unchecked os.Setenv and os.Unsetenv returns in the specs added while the pre-commit hook was being skipped. Restoring an env var is exactly the place a silent failure leaks state into the next spec, so assert on it rather than suppressing the linter. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): make the mtp tag authoritative for serving-feature ranking Variant auto-selection ranks survivors by fit, then engine, then serving feature, then size. The serving-feature lookup read only whole alphanumeric segments of a variant's entry name, because tags were inconsistent: every dflash entry carried a dflash tag, but only 7 of 20 MTP entries carried an mtp tag. Tag the 13 untagged MTP entries, then teach the lookup to read tags as well as names. A tag is now the authoritative signal and is compared whole and case-insensitively, which is safe precisely because a tag is a deliberate declaration rather than free text: there is no word-inside-a-word failure mode, so the segment splitting the name half needs is unnecessary there. The name check stays as a fallback rather than being replaced. Switching to tags only would have regressed the six already-grouped entries on the day it shipped, and would depend on tagging discipline that does not exist yet. The lookup still names no feature, so adding one remains a one-line edit to servingFeaturePreferenceTokens. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): make a declared tag the sole serving-feature signal Variant auto-selection ranks survivors by fit, then engine, then serving feature, then size. The serving-feature lookup recognised a speculative build by either a declared tag or a whole segment of its entry name. Drop the name half: a tag is now the only signal. A name is author-supplied free text and a naming convention is not a contract, so reading a marker out of one infers a capability nobody declared. The gallery already had the failure in it: the four NVFP4 entries name MTP-bearing weights while setting no option that enables speculative decoding, and being live variants they were winning the feature axis without answering any faster. overrides.options was considered as the replacement and rejected. It carries spec_type:draft-mtp / spec_type:draft-dflash, which is what actually turns the feature on, but that spelling is llama.cpp's config vocabulary: ds4 spells the same feature mtp_path and sglang spells it speculative_algorithm in a referenced config. Keying a cross-backend ranking decision on one backend's option syntax would rank the other backends' builds as plain. Options are the curation-time check instead, and never reach the selection logic. With no fallback left, tag correctness is load bearing, so audit every entry against the rule "tagged when the entry configures that feature, in whatever vocabulary its backend uses". Three entries configure MTP untagged and gain the tag (hy3, glm-5.2, qwythos-9b-claude-mythos-5-1m, all spec_type:draft-mtp with no marker in their names). Four carry the tag while configuring nothing and lose it: qwen3.6-27b-nvfp4-mtp, qwen3.6-35b-a3b-nvfp4-mtp, qwopus3.6-27b-coder-mtp-nvfp4 and qwopus3.6-27b-v2-mtp-nvfp4, whose only option is use_jinja:true. The dflash side was checked independently rather than assumed consistent: all five dflash entries declare spec_type:draft-dflash and all five are tagged, so it needed no edits. Four entries keep a tag that a literal spec_type-only reading would strip, because they configure MTP through a different backend: deepseek-v4-flash-q2-mtp via ds4's mtp_path/mtp_draft, and the three sglang entries via speculative_algorithm in their referenced configs. Stripping those would contradict the reason spec_type was rejected as the signal and would demote four genuinely faster builds to plain. The index was edited by line insertion and deletion only, never round-tripped through a serializer. A resolved-tag diff across all 1272 named entries, taken after merge keys are applied, shows exactly these 7 changing and no entry gaining or losing a tag through an anchor. The two specs that pinned the name fallback are inverted rather than deleted, since a name silently promoting a build is the regression worth guarding. The whole-token guard survives on the tag path, where smtp must still not match mtp. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): make deepseek-v4-flash variant targets installable Clicking install on deepseek-v4-flash failed with "invalid gallery model". The parent entry is fine, but all four entries it was grouped with declared neither url: nor config_file:, and applyModel needs one of the two to have anything to build a config from. They carry urls: (plural), the informational HuggingFace link list, which is a different field. None of the four was ever independently installable, so grouping them routed a previously-working install into a broken entry. Give each the url: the parent already resolves through. virtual.yaml is a no-op base, and applyModel passes overrides to InstallModel separately from the fetched config, so backend: ds4, the parameters and the ssd/mtp options all still land exactly as authored. This is the same pattern the parent and many other GGUF entries in the index already use. Add the lint rule that should have caught this. checkVariantReferences only proved a target exists and is not itself a parent, which is structural validity: an entry can exist, declare no variants, and still be uninstallable. checkVariantTargetsInstallable mirrors applyModel's precondition instead, and names the parent, the target and the missing fields, because whoever hits it is reading a gallery entry and has no reason to know applyModel exists. The two index-driven resolution specs live in their own Ordered container: an Ordered container stops at its first failure, so sharing one with the lint rules let a lint breach skip them silently. Nine further entries gallery-wide have the same defect and are unrelated to variants, so they are broken installs that predate this branch. They are left alone here rather than buried in a regression fix, and widening the rule to cover every entry is deferred with them so the gate can ratchet up in one step instead of needing a skip list. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): install entries with no url or config_file on an empty base applyModel had three branches: fetch a base config from url:, build one from an inline config_file:, or fail with "invalid gallery model". An entry declaring neither is now installed on an empty base config, with overrides: and files: supplying everything. This is what the ~345 entries pointing at gallery/virtual.yaml were already getting. That stub is five lines carrying name, description and license. description and license are overwritten from the gallery entry immediately after the fetch, and the name never reaches disk because InstallModel prefers the install name. Crucially applyModel passes model.Overrides to InstallModel as a separate argument rather than merging it into the fetched config, so nothing an author writes depends on that base existing. The fetch bought a round trip to GitHub and nothing else. That makesf4ef80173the wrong fix, so it is unwound. The four url: lines it added to the deepseek-v4-flash variants are reverted: they are a pointless network fetch now, and the family installs without them. Relaxing the branch would hide a real authoring mistake, so a payload rule replaces the base-config rule. An entry with no url, no config_file, no overrides and no files installs nothing and would leave an empty model directory while reporting success, so it is refused by name. The caller's request counts toward the payload, because its overrides and files are merged into the install exactly as the entry's own are. urls: (plural) is the informational link list and does not count, which is what the four entries that shipped broken had and why they were still uninstallable. checkVariantTargetsInstallable asserted every variant target declares a url: or a config_file:, which is no longer true and would now reject correct authoring. checkEntriesInstallSomething pins what survives instead, and covers every entry rather than only variant targets: the hazard is a half-written stanza and a parent can be one as easily as a target. The old rule was scoped to targets precisely because nine unrelated entries would have failed a gallery-wide version; those nine are valid now, so the deferred ratchet happens here in one step. 1280 entries, zero violations. Those nine (aurore-reveil_koto-small-7b-it, lfm2-1.2b, the six liquidai_lfm2 entries and deepseek-v4-pro-q2-ssd) become installable for free. Each carries overrides: and files:, and one of them is driven through the real install path in a spec. The no-fetch spec is paired rather than bare: an assertion that nothing was fetched proves nothing unless something could have been, so a control runs the same fixture with a url: pointing at a base config that is not there and asserts the install fails. Only then does the identical fixture without the url passing mean the read was skipped. Follow-up, deliberately not here: the ~345 entries still naming virtual.yaml can drop their url:. That is 345 index edits with their own risk, and mixing them in would bury this change. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ui(models): let search bypass the variant collapse, drop the toggle The models page collapsed the gallery to one row per model by default and offered a toggle to see every individual build. Because the collapse composed with the search term, a build another entry offers as a variant could not be found by typing its name, so the toggle was the only way to reach those builds in the UI. A user who typed a name they knew existed got "no models found", which reads as "that model does not exist". Collapse is for browsing; search is for finding. An explicit search term now bypasses the collapse in the listing handler, so a name lookup returns matching entries whether or not a parent offers them. The term is trimmed once at the top of the handler, so whitespace is neither a search nor a bypass; previously an untrimmed blank term also narrowed the listing to whatever contained a space. Tag and backend deliberately do not bypass: they refine a listing the user is still reading rather than name an entry already known to exist. That makes the toggle redundant, so it goes, along with its i18n strings in all six locales, its localStorage persistence, its participation in "Clear filters" and the empty-state hint telling users to turn it off. The hint was doubly stale: it pointed at a control that no longer exists, and it was untrue exactly when a user has a search term, since searching now sees every build. The page always requests the collapsed listing. The stored preference key is left inert rather than cleaned up: nothing reads it, so a user who had the toggle off simply gets the collapsed view. collapse_variants stays on the API, off by default, because other clients want either view and the UI dropping its control is no reason to remove a working parameter. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): give the models gallery filter form a deliberate structure The filter area had accreted controls into one undifferentiated flow. The "Fits in GPU" toggle and the backend select were direct children of .filter-bar, the same wrapping container as the 18 taxonomy chips, so their position was decided by how many chips happened to wrap at the current width rather than by any layout intent. At narrow widths they were pushed past the right edge of that container's horizontal scroll and became unreachable entirely. Restructure into three bands inside the house .filter-bar-group wrapper that components/FilterBar.jsx already uses on Backends and the System tabs: 1. query scope: search plus the backend select 2. taxonomy: the chip row, alone, free to wrap 3. refinements: fits-in-GPU and context size, under a hairline rule The backend select leads the chips rather than trailing them because picking a backend disables the use cases that backend cannot serve, so it gates the row below it. Fits-in-GPU and context size share a band because they are one control group: the context size is the length the VRAM estimate is computed at, and that estimate is what the fits filter tests against. Chips had no visible keyboard focus indicator. The global focus ring is wrapped in :where(), so it carries the specificity of a bare :focus-visible, ties with .filter-btn and loses on source order, leaving focused chips showing their resting drop shadow. Restate the ring where it outranks both resting and hover. Also: aria-pressed on the chips, a real label association and aria-valuetext on the context slider (it steps over an index, so it announced "2"), disabled chip styling moved off inline styles, a prefers-reduced-motion block for the chip transition, and the hard-coded English "Context:" moved into all seven locales. No behaviour change: same filters, same state, same requests. Page reset on change, localStorage persistence and "Clear filters" verified unchanged. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): let the models recommendations panel fade into the background The "Recommended for your hardware" strip rendered at full height on every visit regardless of how many models were already installed, costing 186px at 1600px wide (287px at 1100px, where its cards wrapped to two rows) and pushing the first gallery row to y=554 / y=703. Make its prominence track how much the user still needs it. The panel now defaults to a one-line summary once anything is installed, and both the collapse choice and the existing dismissal persist: collapsed = explicit user choice, if one exists : installedCount > 0 The preference is three-valued on purpose. A boolean cannot tell "the user expanded it" apart from "the user has never chosen", and those need opposite handling when the installed count later crosses zero: someone who deliberately opened the panel on an empty instance should not have it collapse out from under them when their first model finishes installing. Collapsed keeps the card, icon, title and a suggestion count, so the panel is recovered by clicking what you are already looking at rather than by hunting. Expanded is unchanged, because for a user with nothing installed it was never the problem. Collapsed reclaims 145px at 1600 and 420, and 246px at 1100. Models.jsx gains a statsLoaded flag: stats initializes to installed:0, so reading it before the fetch resolves would render expanded and collapse a frame later, which is exactly the layout shove this removes. The dismissal key moves to the page's localai-models-* convention; the old localai_rec_models_dismissed is still read, never written, so an existing dismissal is honoured rather than resurrected by the rename. Accessibility: the disclosure is a real button whose accessible name is the visible title alone, with state on aria-expanded and aria-controls resolving in both states, because the grid is hidden via the hidden attribute rather than unmounted. That also keeps the four install buttons out of the tab order while collapsed. The app's global focus ring applies; no per-component outline is added, per the warning in App.css. Reveal animates opacity and transform only, never height, and both it and the chevron rotation are disabled under prefers-reduced-motion. Only en had a recommended block, so the other six locales were falling back to English for the whole panel. Translated the complete block rather than adding one orphaned key to files that would still render the title in English. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(downloader): recover from a leftover .partial on non-HTTP URIs An interrupted download leaves a `<file>.partial` behind. The partial handling in DownloadFileWithContext gated resume on `err == nil && uri.LooksLikeHTTPURL()`, so for any URI that is not literally http(s) the branch fell through to `else if !errors.Is(err, os.ErrNotExist)`, which with a nil err is true. The download then failed with an error wrapping nil: failed to check file ".../Ternary-Bonsai-27B-Q2_g64.gguf" existence: <nil> Every gallery file URI uses `huggingface://`, so a single interrupted download made that model permanently uninstallable until someone deleted the partial by hand. The `<nil>` in the message compounded it by pointing debugging at a filesystem failure that never happened. Restructure the handling as an explicit switch over the four real states: partial exists and is resumable, partial exists and is not resumable (discard and restart, as already done for an HTTP server without range support), no partial, and a genuine stat failure. The error branch is now only reachable with a non-nil error, names the path that was actually stat'd, and wraps with %w. Discarding is required for correctness and not merely convenience: the writer opens the partial with O_APPEND, so an un-resumed download would concatenate a fresh body onto stale bytes. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): tell the models gallery's variant rows apart, and let browsing see every build Both variant surfaces rendered name, backend and size. For two builds of one model that is close to no information: a variant exists precisely because the same weights are offered another way, so the backend usually matches and the sizes usually land within a few hundred megabytes. Comparing ternary-bonsai-27b-pq2 against ternary-bonsai-27b-q2-g64 meant reading two names that differ by a suffix nobody has defined anywhere in the UI. Report the quantization and the serving features on VariantView, and derive both server-side from the referenced entry rather than parsing names in the browser, so every client reads the same format out of the same file the installer will hand the backend. Quantization comes from overrides.parameters.model first, falling back to the file list. That order is load bearing: entries routinely ship a vision tower alongside the language model at a different quantization, so reading the file list first reports the mmproj's format. Matching walks `-` and `.` delimited segments right to left; `_` deliberately does not split, because it separates the parts INSIDE a quant token and splitting on it reports Q4 for a Q4_K_M build. A second, looser pass takes a segment's `_`-delimited tail, which catches the gemma-4-E2B_q4_0-it.gguf style; it runs second so a precise match can never lose to a fuzzy one further right in the name. An entry naming no format reports nothing, which is the honest answer for a backend served from a directory of weights. Features are the same tag-against-vocabulary match servingFeatureRank already ranks on, over the same host preference list. A build can therefore never be shown as faster than one selection did not actually reward, nor rewarded without being shown; a spec pins that agreement rather than trusting it. The compact dropdown gets the quantization on its meta line and the bare feature token. The detail row, which has the room, gets the quantization as its own monospaced column so precision lines up down the list, and the feature spelled out, because DFLASH names nothing to a user who has not met it. The referenced entry's description stays out of both: the detail row already renders the parent's prose above the table, and a second block per variant would push a three-variant list past a screen to restate what the columns now say precisely. The collapse toggle comes back.462583f38dropped it once search bypassed the collapse, on the reasoning that nothing was unreachable any more. That holds for finding a build whose name you know and does not hold for browsing: no sequence of actions enumerated the 68 builds the default view hides. Collapse is for browsing and search is for finding, and the toggle was the browsing half. It goes in the refinements band0d4823362established, not back among the taxonomy chips where its position depended on how many chips happened to wrap. It leads that band because it decides how many rows the other two refine over, and because unlike fits-in-GPU it is unconditional: a host with no GPU still browses. The search bypass is untouched and re-checked by a spec in the toggle's default state, since restoring the control must not restore the dead end it replaced. The empty-state hint returns but only without a search term, because a term bypasses the collapse and the hint would otherwise point at a control that cannot change the result. The stored preference reads 'on'/'off' only: an older build wrote '1'/'0' from an effect that ran on mount, so those record that the page was opened, not that anyone chose a view. Also fixes a latent flake it exposed. The collapse_variants spec compared whole response bodies byte for byte, and the listing envelope carries live host telemetry that drifts between two calls milliseconds apart, so it was asserting on the machine's memory pressure. It now compares everything the parameter governs -- the entries, their serialization and the paging -- and is green 25/25 where it was failing about one run in three. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): let the models gallery show a variant's full details The variant list in an entry's expanded detail row says how the builds differ: name, backend, quantization, size, and the auto-selected, base and serving-feature markers. It cannot say what any one of them is. A variant's own description, tags, license, source links and file list are unreachable anywhere in the UI, because while the collapse is on a variant has no gallery row of its own. Give each variant row an info control that reveals its entry, rendered by the same ModelDetail a top-level row gets, so a field added to the detail view appears here too. variantData is withheld from the nested render: a variant may declare variants of its own, and recursing would nest a picker inside a picker two levels deep already. An inline disclosure rather than a modal. The control sits inside a table row that is already expanded, inside a variant list within that; a dialog opened from there stacks a dismissal on a dismissal for a handful of extra fields about the entry the user is already reading, and breaks the page's own expand idiom. The third level is carried by an inset and a left rule instead of another card. The entry is fetched by exact name from the listing, once, on first use. The listing already returns every field the detail view renders, and a search term bypasses the variant collapse server-side, so no new endpoint is needed and neither the listing nor DescribeVariants gains any work. Expanding a row costs nothing; a variant nobody opens costs nothing. A name the listing no longer returns is stated, not blanked: an empty panel reads as a rendering fault rather than as a lookup that came back empty. The control is a sibling of the install button, not a descendant, so asking about a build can never install it. The variant list keeps its content-sized columns via a trailing filler track instead of max-content sizing, so the rows are unchanged while the panel spanning them gets the pane width its file table needs. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): let search respect the collapse instead of switching it off The models listing collapsed to one row per model, and an explicit search term turned that off wholesale. Searching while collapsed therefore answered with the individual builds a parent already offers, which are exactly the rows the view the user asked for has no place for: typing "mtp" returned qwen3.6-27b-nvfp4-mtp, a row that is invisible the moment the box is cleared. The bypass was the right shape of fix for the wrong half of the problem. What a search must not do is answer "no models found" for a build the gallery does hold; that does not require abandoning the grouping the user asked for. So the term is now matched against every entry either way, hidden builds included, and the collapse decides how a match is reported rather than which matches exist. Collapsing stops being a filter that drops rows and becomes a substitution: a match on a build another entry offers is reported as that entry, the one installable in its own right. Nothing becomes unfindable and nothing comes back that the requested view cannot show. Substitution happens after search, tag and backend, so every filter is judged against the build that really carries the name, tag or backend rather than against a parent that merely offers it; the other order would let backend=vllm match a parent whose own backend is something else. The price is that the surfaced row shows the parent's own metadata while the match was on a variant, which is what grouping means, and the alternative is claiming the gallery holds no such build. It happens before the count and the page math, so both describe the rows actually handed out rather than the matches that produced them. A parent already in the result keeps its own position and absorbs its matching variants there, which is what leaves the browsing listing ordered exactly as it was; a parent surfaced only by a variant takes the position of the first variant that surfaced it. Either way it appears once, however many of its builds matched and whether or not it matched itself. Search preserves gallery order rather than scoring, so a surfaced parent has a real position rather than an invented one. VariantParents never reports an entry that declares variants of its own, so a parent is never itself hidden and one hop always lands on a visible row. The handler follows exactly one anyway: refusing the second is what makes a gallery the linter would have rejected terminate rather than loop. The empty-state hint pointing at the toggle goes with it for every server-side filter. Substitution means a match is always reported as some row, so the collapse can no longer be why a term, a chip or a backend came back empty, and naming it there sends the user to a control that cannot change the result. It survives for the fits filter alone, which runs in the browser after the substitution and judges the surfaced entry's own size: there the build that fits really can be filtered out along with a parent that does not. Searching a build's exact name while collapsed now answers with its parent, so the result no longer contains the string the user typed. That is intended, and the row is the one they can act on, but it is a real rough edge: nothing on the row explains the connection. Closing it properly means reporting which variant matched so the UI can say so, which the listing does not do today. ResetGalleryModelCache is added for tests. The model cache is a package global keyed by nothing, so a background refresh one spec triggers can land in the middle of the next and answer it with the previous spec's gallery; the extra specs here made that fail about one run in five. It waits for the in-flight refresh to publish before clearing, since clearing alone only narrows the window. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
9648 lines
249 KiB
CSS
9648 lines
249 KiB
CSS
/* Layout */
|
||
.app-layout {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
background-color: var(--color-bg-primary);
|
||
}
|
||
|
||
.main-content {
|
||
flex: 1;
|
||
margin-left: var(--sidebar-width);
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: margin-left var(--duration-normal) var(--ease-default);
|
||
}
|
||
|
||
.sidebar-is-collapsed .main-content {
|
||
margin-left: var(--sidebar-width-collapsed);
|
||
}
|
||
|
||
.main-content-inner {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.app-layout-chat {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
}
|
||
|
||
.app-layout-chat .main-content {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.app-layout-chat .main-content-inner {
|
||
overflow: hidden;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Footer */
|
||
.app-footer {
|
||
background: transparent;
|
||
border-top: 1px solid var(--color-border-divider);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
margin-top: auto;
|
||
}
|
||
|
||
.app-footer-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.app-footer-version {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.app-footer-version a {
|
||
color: var(--color-text-muted);
|
||
text-decoration: none;
|
||
transition: color 150ms;
|
||
}
|
||
|
||
.app-footer-version a:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.app-footer-links {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.app-footer-links a {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
text-decoration: none;
|
||
transition: color 150ms;
|
||
}
|
||
|
||
.app-footer-links a:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.app-footer-copyright {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.app-footer-copyright a {
|
||
color: var(--color-text-muted);
|
||
text-decoration: none;
|
||
transition: color 150ms;
|
||
}
|
||
|
||
.app-footer-copyright a:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* Mobile header */
|
||
.mobile-header {
|
||
display: none;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-secondary);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
}
|
||
|
||
.hamburger-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-primary);
|
||
font-size: 1.25rem;
|
||
cursor: pointer;
|
||
padding: var(--spacing-xs);
|
||
}
|
||
|
||
.mobile-title {
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mobile-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.mobile-header-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-primary);
|
||
font-size: 1.05rem;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
padding: 10px;
|
||
border-radius: var(--radius-full);
|
||
transition: background var(--duration-fast) var(--ease-default);
|
||
}
|
||
.mobile-header-btn:hover {
|
||
background: var(--color-bg-hover);
|
||
}
|
||
.mobile-header-btn:focus-visible {
|
||
outline: 2px solid var(--color-focus-ring);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.mobile-header-avatar {
|
||
padding: 4px;
|
||
}
|
||
.mobile-header-avatar img {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.mobile-header-avatar .fa-user-circle {
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.sidebar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: var(--sidebar-width);
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
background: var(--color-bg-secondary);
|
||
border-right: 1px solid var(--color-border-subtle);
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 50;
|
||
overflow-y: auto;
|
||
box-shadow: var(--shadow-sidebar);
|
||
transition: width var(--duration-normal) var(--ease-spring),
|
||
transform var(--duration-normal) var(--ease-spring);
|
||
will-change: transform;
|
||
}
|
||
|
||
.sidebar-overlay {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--spacing-sm) var(--spacing-sm);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
min-height: 44px;
|
||
}
|
||
|
||
.sidebar-logo-link {
|
||
display: block;
|
||
}
|
||
|
||
.sidebar-logo-img {
|
||
width: 100%;
|
||
max-width: 120px;
|
||
height: auto;
|
||
padding: 0 var(--spacing-xs);
|
||
}
|
||
|
||
.sidebar-logo-icon {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-logo-icon-img {
|
||
width: 28px;
|
||
height: 28px;
|
||
}
|
||
|
||
.sidebar-close-btn {
|
||
display: none;
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-secondary);
|
||
font-size: 1.25rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.sidebar-nav {
|
||
flex: 1;
|
||
padding: 2px 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sidebar-section {
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.sidebar-section-title {
|
||
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xs);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--color-text-secondary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-section-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: color var(--duration-fast);
|
||
}
|
||
|
||
.sidebar-section-toggle:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.sidebar-section-chevron {
|
||
font-size: 0.5rem;
|
||
opacity: 0.6;
|
||
transition: transform var(--duration-fast), opacity var(--duration-fast);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-section-toggle:hover .sidebar-section-chevron {
|
||
opacity: 1;
|
||
}
|
||
|
||
.sidebar-section-toggle.open .sidebar-section-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.nav-item {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: 8px var(--spacing-md) 8px calc(var(--spacing-sm) + 2px);
|
||
color: var(--color-text-secondary);
|
||
text-decoration: none;
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
transition: color var(--duration-normal) var(--ease-spring),
|
||
background var(--duration-normal) var(--ease-spring),
|
||
box-shadow var(--duration-normal) var(--ease-spring);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.nav-item:hover:not(.active) {
|
||
color: var(--color-text-primary);
|
||
background: var(--color-surface-elevated);
|
||
}
|
||
|
||
.nav-item.active {
|
||
color: var(--color-primary);
|
||
background: var(--color-primary-light);
|
||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
.nav-icon {
|
||
width: 18px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.nav-label {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
transition: opacity 150ms ease;
|
||
}
|
||
|
||
/* Sidebar nav polish: inset, rounded "pill" items give the nav a lighter,
|
||
more intentional rhythm than full-bleed bars. Scoped to .sidebar-nav so the
|
||
shared .nav-item class (reused by the admin console rail) keeps its own
|
||
treatment. */
|
||
.sidebar-nav .nav-item {
|
||
margin: 1px var(--spacing-sm);
|
||
padding-left: var(--spacing-sm);
|
||
padding-right: var(--spacing-sm);
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
.sidebar-nav .nav-item.active {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-primary);
|
||
font-weight: var(--font-weight-medium);
|
||
box-shadow: none;
|
||
}
|
||
.sidebar-nav .nav-item.active .nav-icon { color: var(--color-primary); }
|
||
/* Align tier labels with the inset item text (item margin + icon padding). */
|
||
.sidebar-nav .sidebar-section-title {
|
||
padding-left: var(--spacing-md);
|
||
padding-right: var(--spacing-md);
|
||
}
|
||
|
||
.nav-external {
|
||
font-size: 0.55rem;
|
||
margin-left: auto;
|
||
opacity: 0.5;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
border-top: 1px solid var(--color-border-subtle);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-xs);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.sidebar-user {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
width: 100%;
|
||
padding: var(--spacing-xs) 0;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-user-avatar {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: var(--radius-full);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-user-avatar-icon {
|
||
font-size: 1.25rem;
|
||
color: var(--color-text-muted);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-user-link {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: none;
|
||
border: none;
|
||
padding: 2px var(--spacing-xs);
|
||
margin: -2px calc(-1 * var(--spacing-xs));
|
||
border-radius: var(--radius-sm);
|
||
color: inherit;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
transition: background var(--duration-fast), color var(--duration-fast);
|
||
}
|
||
|
||
.sidebar-user-link:hover {
|
||
background: var(--color-bg-hover);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.sidebar-user-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
text-align: left;
|
||
}
|
||
|
||
.sidebar-logout-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 2px 4px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.75rem;
|
||
flex-shrink: 0;
|
||
transition: color var(--duration-fast);
|
||
}
|
||
|
||
.sidebar-logout-btn:hover {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-user {
|
||
justify-content: center;
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-user-link {
|
||
flex: 0;
|
||
margin: 0;
|
||
padding: 2px;
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-user-name,
|
||
.sidebar.collapsed .sidebar-logout-btn {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-collapse-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
transition: color var(--duration-fast), background var(--duration-fast);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-collapse-btn:hover {
|
||
color: var(--color-text-primary);
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
/* Collapsed sidebar (desktop only) */
|
||
.sidebar.collapsed {
|
||
width: var(--sidebar-width-collapsed);
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-logo-link {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-logo-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-header {
|
||
justify-content: center;
|
||
}
|
||
|
||
.sidebar.collapsed .nav-label,
|
||
.sidebar.collapsed .nav-external,
|
||
.sidebar.collapsed .sidebar-section-title {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-section-chevron {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar.collapsed .nav-item {
|
||
justify-content: center;
|
||
padding: 8px 0;
|
||
border-left-width: 2px;
|
||
}
|
||
|
||
.sidebar.collapsed .nav-icon {
|
||
width: auto;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.sidebar.collapsed .sidebar-footer {
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.sidebar.collapsed .theme-toggle {
|
||
padding: 4px;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.sidebar.collapsed .theme-toggle .nav-label {
|
||
display: none;
|
||
}
|
||
|
||
/* Theme toggle */
|
||
.theme-toggle {
|
||
background: none;
|
||
border: 1px solid var(--color-border-subtle);
|
||
color: var(--color-text-secondary);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
transition: color var(--duration-fast) var(--ease-default),
|
||
border-color var(--duration-fast) var(--ease-default),
|
||
background var(--duration-fast) var(--ease-default);
|
||
}
|
||
.theme-toggle:hover {
|
||
color: var(--color-primary);
|
||
border-color: var(--color-primary-border);
|
||
}
|
||
.theme-toggle__icon {
|
||
display: inline-block;
|
||
animation: themeIconIn var(--duration-normal) var(--ease-default);
|
||
}
|
||
@keyframes themeIconIn {
|
||
from { opacity: 0; transform: rotate(-90deg) scale(0.7); }
|
||
to { opacity: 1; transform: rotate(0) scale(1); }
|
||
}
|
||
|
||
/* Language switcher */
|
||
.language-switcher {
|
||
position: relative;
|
||
display: inline-flex;
|
||
}
|
||
.language-switcher-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.language-switcher-code {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.sidebar.collapsed .language-switcher-code {
|
||
display: none;
|
||
}
|
||
.language-switcher-menu {
|
||
position: absolute;
|
||
bottom: calc(100% + 8px);
|
||
left: 0;
|
||
min-width: 160px;
|
||
background: var(--color-bg-elevated, var(--color-bg-secondary));
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.18));
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 4px;
|
||
z-index: 1000;
|
||
}
|
||
.sidebar.collapsed .language-switcher-menu {
|
||
left: calc(100% + 8px);
|
||
bottom: 0;
|
||
}
|
||
.language-switcher-menu li {
|
||
margin: 0;
|
||
}
|
||
.language-switcher-option {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: transparent;
|
||
border: 0;
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.875rem;
|
||
text-align: left;
|
||
}
|
||
.language-switcher-option:hover {
|
||
background: var(--color-bg-tertiary, rgba(255, 255, 255, 0.04));
|
||
color: var(--color-text-primary);
|
||
}
|
||
.language-switcher-option.active {
|
||
color: var(--color-primary);
|
||
}
|
||
.language-switcher-flag {
|
||
font-weight: 600;
|
||
font-size: 0.7rem;
|
||
width: 22px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.language-switcher-name {
|
||
flex: 1;
|
||
}
|
||
.language-switcher-check {
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
/* App boot fallback (rendered while initial i18n namespaces load) */
|
||
.app-boot-spinner {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-bg-primary, #111);
|
||
}
|
||
.app-boot-spinner-dot {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
border: 3px solid var(--color-border-subtle, rgba(255, 255, 255, 0.15));
|
||
border-top-color: var(--color-primary, #4f8cff);
|
||
animation: app-boot-spin 0.8s linear infinite;
|
||
}
|
||
@keyframes app-boot-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Operations bar */
|
||
.operations-bar {
|
||
background: var(--color-bg-secondary);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
}
|
||
|
||
.operation-text {
|
||
font-family: var(--font-mono);
|
||
}
|
||
.operation-progress {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.operation-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-xs) 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.operation-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
flex: 2 1 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.operation-info > .operation-text {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.operation-spinner {
|
||
width: 16px;
|
||
height: 16px;
|
||
flex-shrink: 0;
|
||
box-sizing: border-box;
|
||
border: 2px solid var(--color-border-default);
|
||
border-top-color: var(--color-primary);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
display: inline-block;
|
||
}
|
||
|
||
.operation-text {
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.operation-progress {
|
||
font-size: 0.75rem;
|
||
color: var(--color-primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.operation-bar-container {
|
||
flex: 0 1 160px;
|
||
min-width: 80px;
|
||
height: 3px;
|
||
background: var(--color-surface-sunken);
|
||
border-radius: var(--radius-full);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.operation-bar {
|
||
height: 100%;
|
||
background: var(--color-primary);
|
||
border-radius: var(--radius-full);
|
||
transition: width var(--duration-slow) var(--ease-spring);
|
||
animation: opsBarBreathe 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
/* Inline install indicator — used in table rows (Models, Backends) */
|
||
.inline-install {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.inline-install__row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
min-width: 0;
|
||
}
|
||
.inline-install__label {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-primary);
|
||
font-weight: var(--font-weight-medium);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.operation-cancel {
|
||
flex-shrink: 0;
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 4px 6px;
|
||
font-size: 0.875rem;
|
||
}
|
||
.operation-cancel:hover {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
/* Operations bar: per-node breakdown (multi-worker installs) */
|
||
.operation-expand {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 0 var(--spacing-xs);
|
||
font-size: var(--text-xs);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
.operation-expand:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
.operation-expand-label {
|
||
font-size: var(--text-xs);
|
||
}
|
||
|
||
.operation-nodes-list {
|
||
list-style: none;
|
||
margin: var(--spacing-xs) 0 0;
|
||
padding: var(--spacing-xs) 0 0;
|
||
border-top: 1px solid var(--color-border-subtle);
|
||
flex-basis: 100%;
|
||
width: 100%;
|
||
}
|
||
.operation-node {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-xs) 0;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
flex-wrap: wrap;
|
||
}
|
||
.operation-node-status {
|
||
padding: 2px 6px;
|
||
border-radius: var(--radius-md);
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.025em;
|
||
white-space: nowrap;
|
||
}
|
||
.operation-node-status-success {
|
||
background: var(--color-success-light);
|
||
color: var(--color-success);
|
||
}
|
||
.operation-node-status-error {
|
||
background: var(--color-error-light);
|
||
color: var(--color-error);
|
||
}
|
||
.operation-node-status-queued {
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.operation-node-status-running_on_worker {
|
||
background: var(--color-warning-light);
|
||
color: var(--color-warning);
|
||
}
|
||
.operation-node-status-downloading {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-primary);
|
||
}
|
||
.operation-node-name {
|
||
font-weight: 500;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.operation-node-file {
|
||
font-family: var(--font-mono);
|
||
color: var(--color-text-tertiary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 30ch;
|
||
white-space: nowrap;
|
||
}
|
||
.operation-node-bytes {
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.operation-node-pct {
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-primary);
|
||
font-weight: 500;
|
||
}
|
||
.operation-node-error {
|
||
color: var(--color-error);
|
||
}
|
||
.operation-node-bar-container {
|
||
flex-basis: 100%;
|
||
height: 3px;
|
||
background: var(--color-surface-sunken);
|
||
border-radius: var(--radius-full);
|
||
overflow: hidden;
|
||
margin-top: 0.25rem;
|
||
}
|
||
.operation-node-bar {
|
||
height: 100%;
|
||
background: var(--color-primary);
|
||
border-radius: var(--radius-full);
|
||
transition: width var(--duration-slow, 0.3s) var(--ease-spring, ease);
|
||
}
|
||
|
||
/* Toast */
|
||
.toast-container {
|
||
position: fixed;
|
||
top: var(--spacing-lg);
|
||
right: var(--spacing-lg);
|
||
z-index: 1100;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.toast {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
color: var(--color-text-primary);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.75rem;
|
||
letter-spacing: -0.005em;
|
||
animation: toastSlideIn var(--duration-normal) var(--ease-spring);
|
||
min-width: 280px;
|
||
}
|
||
|
||
.toast-enter {
|
||
opacity: 0;
|
||
transform: translateX(12px);
|
||
}
|
||
|
||
.toast-exit {
|
||
opacity: 0;
|
||
transform: translateX(12px);
|
||
transition: opacity var(--duration-fast) var(--ease-spring),
|
||
transform var(--duration-fast) var(--ease-spring);
|
||
}
|
||
|
||
.toast-success {
|
||
box-shadow: inset 3px 0 0 var(--color-success), var(--shadow-sm);
|
||
border-color: var(--color-border-subtle);
|
||
}
|
||
.toast-error {
|
||
box-shadow: inset 3px 0 0 var(--color-error), var(--shadow-sm);
|
||
border-color: var(--color-border-subtle);
|
||
}
|
||
.toast-warning {
|
||
box-shadow: inset 3px 0 0 var(--color-warning), var(--shadow-sm);
|
||
border-color: var(--color-border-subtle);
|
||
}
|
||
.toast-info {
|
||
box-shadow: inset 3px 0 0 var(--color-info), var(--shadow-sm);
|
||
border-color: var(--color-border-subtle);
|
||
}
|
||
|
||
.toast-close {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
color: inherit;
|
||
opacity: 0.6;
|
||
cursor: pointer;
|
||
padding: 2px;
|
||
}
|
||
.toast-close:hover { opacity: 1; }
|
||
.toast-link {
|
||
font-size: 0.75rem;
|
||
color: inherit;
|
||
opacity: 0.8;
|
||
text-decoration: underline;
|
||
white-space: nowrap;
|
||
margin-left: var(--spacing-xs);
|
||
}
|
||
.toast-link:hover { opacity: 1; }
|
||
|
||
/* Chat error trace link */
|
||
.chat-error-trace-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
text-decoration: none;
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
.chat-error-trace-link:hover {
|
||
color: var(--color-primary);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* Spinner */
|
||
.spinner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.spinner-ring {
|
||
border: 3px solid var(--color-border-subtle);
|
||
border-top-color: var(--color-primary);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
.spinner-sm .spinner-ring { width: 16px; height: 16px; }
|
||
.spinner-md .spinner-ring { width: 24px; height: 24px; }
|
||
.spinner-lg .spinner-ring { width: 40px; height: 40px; }
|
||
|
||
/* Skeleton shimmer placeholders */
|
||
.skeleton {
|
||
display: block;
|
||
border-radius: var(--radius-sm);
|
||
background: linear-gradient(
|
||
100deg,
|
||
var(--color-surface-sunken) 30%,
|
||
var(--color-surface-hover) 50%,
|
||
var(--color-surface-sunken) 70%
|
||
);
|
||
background-size: 200% 100%;
|
||
animation: skeletonShimmer 1.4s ease-in-out infinite;
|
||
}
|
||
.skeleton--line { height: 0.85em; margin: 0.35em 0; width: 100%; }
|
||
.skeleton--block { height: 100%; width: 100%; border-radius: var(--radius-md); }
|
||
@keyframes skeletonShimmer {
|
||
from { background-position: 200% 0; }
|
||
to { background-position: -200% 0; }
|
||
}
|
||
|
||
/* Model selector */
|
||
.model-selector {
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-primary);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: border-color var(--duration-fast);
|
||
min-width: 180px;
|
||
}
|
||
.model-selector:focus {
|
||
border-color: var(--color-border-strong);
|
||
}
|
||
|
||
/* Resource monitor */
|
||
.resource-monitor {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-md);
|
||
}
|
||
|
||
.resource-monitor-title {
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
|
||
.resource-gpu-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.resource-gpu-card {
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-sm);
|
||
}
|
||
|
||
.resource-gpu-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
|
||
.resource-gpu-name {
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.resource-gpu-vendor {
|
||
font-size: 0.6875rem;
|
||
padding: 2px 6px;
|
||
background: var(--color-accent-light);
|
||
color: var(--color-accent);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.resource-gpu-stats {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
|
||
.resource-bar-container {
|
||
height: 4px;
|
||
background: var(--color-bg-primary);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.resource-bar {
|
||
height: 100%;
|
||
background: var(--color-primary);
|
||
border-radius: 2px;
|
||
transition: width 500ms ease;
|
||
}
|
||
|
||
.resource-bar-ram {
|
||
background: var(--color-secondary);
|
||
}
|
||
|
||
.resource-no-gpu {
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-muted);
|
||
padding: var(--spacing-sm);
|
||
}
|
||
|
||
.resource-ram {
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
|
||
.resource-ram-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
|
||
.resource-monitor-compact {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.resource-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
/* Common page styles — width archetype is opt-in via modifiers.
|
||
Default cap fits 9-column data tables on ultrawide displays without
|
||
feeling untethered. Add .page--narrow for forms / single-record edit
|
||
views, .page--wide for full-bleed (chat shells, log streams). */
|
||
.page {
|
||
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-2xl);
|
||
width: 100%;
|
||
max-width: var(--page-max, var(--page-max-default));
|
||
margin: 0 auto;
|
||
animation: fadeIn var(--duration-normal) var(--ease-default);
|
||
}
|
||
|
||
.page--narrow { --page-max: var(--page-max-narrow); }
|
||
.page--medium { --page-max: var(--page-max-medium); }
|
||
.page--wide { --page-max: var(--page-max-wide); }
|
||
|
||
.page-header {
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.page-title {
|
||
font-family: var(--font-sans);
|
||
font-size: clamp(1.5rem, 1.15rem + 1.4vw, var(--text-3xl));
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: -0.018em;
|
||
line-height: var(--leading-tight);
|
||
margin-bottom: var(--spacing-xs);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
/* Mid hierarchy tier — between page title and the xs uppercase group labels */
|
||
.section-heading {
|
||
font-family: var(--font-sans);
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-medium);
|
||
letter-spacing: -0.005em;
|
||
line-height: var(--leading-snug);
|
||
color: var(--color-text-primary);
|
||
margin: 0;
|
||
}
|
||
|
||
.page-header--editorial {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-lg);
|
||
flex-wrap: wrap;
|
||
margin-bottom: var(--space-section, var(--spacing-xl));
|
||
}
|
||
.page-header__lead { display: flex; flex-direction: column; gap: var(--spacing-xs); min-width: 0; }
|
||
.page-header__eyebrow {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.14em;
|
||
color: var(--color-eyebrow);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.page-header__eyebrow::before {
|
||
content: '';
|
||
width: 18px;
|
||
height: 1px;
|
||
background: var(--color-eyebrow);
|
||
opacity: 0.7;
|
||
}
|
||
.page-header__supporting { color: var(--color-text-secondary); font-size: var(--text-base); margin: 0; max-width: 60ch; }
|
||
.page-header__meta { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; }
|
||
|
||
.page-subtitle {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
|
||
/* Cards */
|
||
.card {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-lg);
|
||
box-shadow: var(--shadow-subtle), var(--shadow-inset-top);
|
||
transition: border-color var(--duration-normal) var(--ease-spring),
|
||
box-shadow var(--duration-normal) var(--ease-spring),
|
||
transform var(--duration-normal) var(--ease-spring);
|
||
}
|
||
|
||
.card:hover {
|
||
border-color: var(--color-border-strong);
|
||
box-shadow: var(--shadow-sm), var(--shadow-inset-top);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
/* Accent-rail variant — editorial left bar for highlighted cards */
|
||
.card--accent {
|
||
box-shadow: inset 2px 0 0 var(--color-primary), var(--shadow-subtle), var(--shadow-inset-top);
|
||
}
|
||
.card--accent:hover {
|
||
box-shadow: inset 2px 0 0 var(--color-primary), var(--shadow-sm), var(--shadow-inset-top);
|
||
}
|
||
|
||
.card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: var(--spacing-lg);
|
||
}
|
||
|
||
/* Form rows — consistent label+control rhythm */
|
||
.form-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-md) 0;
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
}
|
||
.form-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.form-row__label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.form-row__label-text {
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.form-row__hint {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
line-height: var(--leading-snug);
|
||
}
|
||
.form-row__control {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.form-group__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--color-text-secondary);
|
||
padding: var(--spacing-md) 0 var(--spacing-sm);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.form-group__title i {
|
||
color: var(--color-primary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.form-group__body {
|
||
padding-bottom: var(--spacing-md);
|
||
}
|
||
.form-group__body:last-child {
|
||
padding-bottom: var(--spacing-lg);
|
||
}
|
||
.form-group__actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md) 0 var(--spacing-lg);
|
||
border-top: 1px solid var(--color-border-divider);
|
||
margin-top: var(--spacing-md);
|
||
}
|
||
|
||
/* Form layout grids */
|
||
.form-grid {
|
||
display: grid;
|
||
gap: var(--spacing-md);
|
||
}
|
||
.form-grid-2col {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: var(--spacing-md);
|
||
}
|
||
.form-grid-3col {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: var(--spacing-md);
|
||
}
|
||
@media (max-width: 720px) {
|
||
.form-grid-2col, .form-grid-3col {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.form-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.form-field__label {
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.form-field__hint {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
line-height: var(--leading-snug);
|
||
}
|
||
|
||
/* Button modifiers */
|
||
.btn-full {
|
||
width: 100%;
|
||
}
|
||
|
||
/* Progress bar */
|
||
.progress-bar {
|
||
width: 100%;
|
||
height: 24px;
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-surface-sunken);
|
||
overflow: hidden;
|
||
border: 1px solid var(--color-border-subtle);
|
||
}
|
||
.progress-bar__fill {
|
||
height: 100%;
|
||
background: var(--color-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-primary-text);
|
||
transition: width 300ms var(--ease-default);
|
||
white-space: nowrap;
|
||
padding: 0 var(--spacing-sm);
|
||
}
|
||
.progress-bar__fill--error {
|
||
background: var(--color-error);
|
||
}
|
||
|
||
/* Log tail viewport */
|
||
.log-tail {
|
||
max-height: 180px;
|
||
overflow: auto;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
line-height: var(--leading-snug);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.log-tail__line {
|
||
padding: 1px 0;
|
||
}
|
||
.log-tail__line--error {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
/* Result quote (TTS / Sound prompt echo) */
|
||
.result-quote {
|
||
padding: var(--spacing-md);
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-secondary);
|
||
font-style: italic;
|
||
text-align: center;
|
||
line-height: var(--leading-normal);
|
||
}
|
||
|
||
/* Data table — used by Quantize jobs list, Traces, etc. */
|
||
.data-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.data-table th {
|
||
text-align: left;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--color-text-secondary);
|
||
border-bottom: 1px solid var(--color-border-default);
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.data-table td {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
vertical-align: middle;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.data-table tbody tr {
|
||
transition: background var(--duration-fast);
|
||
}
|
||
.data-table tbody tr:hover {
|
||
background: var(--color-surface-hover);
|
||
}
|
||
.data-table tbody tr.is-selected {
|
||
background: var(--color-primary-light);
|
||
}
|
||
.data-table tbody tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
.data-table__actions {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
justify-content: flex-end;
|
||
}
|
||
.data-table__truncate {
|
||
max-width: 280px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Quantize page */
|
||
.quantize-page__header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
}
|
||
.quantize-form {
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
.quantize-form__quant-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.quantize-form__quant-row > :only-child {
|
||
grid-column: 1 / -1;
|
||
}
|
||
@media (max-width: 520px) {
|
||
.quantize-form__quant-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
.quantize-progress-card {
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
.quantize-progress-card__header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-md);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.quantize-progress-card__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin: 0;
|
||
font-size: var(--text-base);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.quantize-progress-card__title i {
|
||
color: var(--color-primary);
|
||
}
|
||
.quantize-progress-card__status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-md);
|
||
flex-wrap: wrap;
|
||
}
|
||
.quantize-progress-card__message {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.quantize-progress-card .progress-bar {
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.quantize-import-card {
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
.quantize-import-card__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin: 0 0 var(--spacing-md);
|
||
font-size: var(--text-base);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.quantize-import-card__title i {
|
||
color: var(--color-primary);
|
||
}
|
||
.quantize-import-card__row {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.quantize-import-card__name {
|
||
flex: 1;
|
||
min-width: 220px;
|
||
max-width: 320px;
|
||
}
|
||
.quantize-jobs {
|
||
padding: 0;
|
||
overflow: hidden;
|
||
}
|
||
.quantize-jobs__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin: 0;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
font-size: var(--text-base);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.quantize-jobs__title i {
|
||
color: var(--color-primary);
|
||
}
|
||
.quantize-jobs__scroll {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
/* Segmented control (Sound mode toggle etc.) */
|
||
.segmented {
|
||
display: inline-flex;
|
||
padding: 3px;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
margin-bottom: var(--spacing-md);
|
||
gap: 2px;
|
||
}
|
||
.segmented__item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: 6px var(--spacing-md);
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
cursor: pointer;
|
||
transition: background var(--duration-fast), color var(--duration-fast);
|
||
}
|
||
.segmented__item:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
.segmented__item.is-active {
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-primary);
|
||
box-shadow: var(--shadow-subtle);
|
||
}
|
||
|
||
/* Inline checkbox row */
|
||
.checkbox-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: 10px var(--spacing-md);
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-primary);
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: border-color var(--duration-fast);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.checkbox-row:hover {
|
||
border-color: var(--color-border-default);
|
||
}
|
||
.checkbox-row input[type="checkbox"] {
|
||
accent-color: var(--color-primary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Audio result wrapper (TTS/Sound) */
|
||
.audio-result {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: var(--spacing-md);
|
||
width: 100%;
|
||
max-width: 480px;
|
||
}
|
||
.audio-result__player {
|
||
width: 100%;
|
||
}
|
||
.audio-result__actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
justify-content: center;
|
||
}
|
||
|
||
/* Media empty state */
|
||
.media-empty {
|
||
text-align: center;
|
||
color: var(--color-text-muted);
|
||
padding: var(--spacing-xl) var(--spacing-md);
|
||
}
|
||
.media-empty__icon {
|
||
display: block;
|
||
font-size: 2.75rem;
|
||
margin-bottom: var(--spacing-md);
|
||
opacity: 0.35;
|
||
}
|
||
.media-empty p {
|
||
font-size: var(--text-sm);
|
||
margin: 0;
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-xs);
|
||
padding: 0.5rem var(--spacing-md);
|
||
min-height: 34px;
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
font-family: inherit;
|
||
font-weight: var(--font-weight-medium);
|
||
letter-spacing: -0.005em;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: background var(--duration-normal) var(--ease-spring),
|
||
color var(--duration-normal) var(--ease-spring),
|
||
border-color var(--duration-normal) var(--ease-spring),
|
||
box-shadow var(--duration-normal) var(--ease-spring),
|
||
filter var(--duration-normal) var(--ease-spring),
|
||
transform var(--duration-normal) var(--ease-spring);
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn:focus-visible {
|
||
outline: none;
|
||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||
}
|
||
|
||
/* Global focus ring - any interactive that isn't a .btn. This box-shadow ring
|
||
is the single focus technique app-wide (it covers .btn plus the :where(...)
|
||
list below). The strengthened --color-focus-ring keeps it WCAG-AA visible
|
||
(>=3:1), so no separate solid-outline rule is needed; a bare :focus-visible
|
||
outline here would double-ring every element. */
|
||
:where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||
outline: none;
|
||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25), var(--shadow-inset-hi);
|
||
}
|
||
.btn-primary:hover:not(:disabled) {
|
||
filter: brightness(1.06);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3), var(--shadow-inset-hi);
|
||
}
|
||
.btn-primary:focus-visible:not(:disabled) {
|
||
box-shadow: 0 0 0 3px var(--color-focus-ring), var(--shadow-inset-hi);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--color-surface-elevated);
|
||
color: var(--color-text-primary);
|
||
border-color: var(--color-border-default);
|
||
}
|
||
.btn-secondary:hover:not(:disabled) {
|
||
border-color: var(--color-border-strong);
|
||
background: var(--color-surface-hover);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
border-color: transparent;
|
||
}
|
||
.btn-ghost:hover:not(:disabled) {
|
||
background: var(--color-surface-elevated);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: var(--color-error-light);
|
||
color: var(--color-error);
|
||
border-color: var(--color-error-border);
|
||
}
|
||
.btn-danger:hover:not(:disabled) {
|
||
background: var(--color-error);
|
||
color: var(--color-text-inverse);
|
||
border-color: var(--color-error);
|
||
filter: brightness(1.04);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 0.35rem var(--spacing-sm);
|
||
min-height: 28px;
|
||
font-size: var(--text-xs);
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.btn:active:not(:disabled) {
|
||
filter: brightness(0.95);
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
filter: none;
|
||
transform: none;
|
||
}
|
||
|
||
/* Toggle switch */
|
||
.toggle {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 38px;
|
||
height: 22px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
.toggle input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
.toggle__track {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-toggle-off);
|
||
transition: background var(--duration-normal) var(--ease-default);
|
||
}
|
||
.toggle__thumb {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: var(--radius-full);
|
||
background: #ffffff;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: transform var(--duration-normal) var(--ease-default);
|
||
}
|
||
.toggle--on .toggle__track {
|
||
background: var(--color-toggle-on);
|
||
}
|
||
.toggle--on .toggle__thumb {
|
||
transform: translateX(16px);
|
||
}
|
||
.toggle:hover:not(.toggle--disabled) .toggle__track {
|
||
filter: brightness(1.08);
|
||
}
|
||
.toggle:focus-within .toggle__track {
|
||
box-shadow: 0 0 0 3px var(--color-border-focus);
|
||
}
|
||
.toggle--disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* Inputs — sunken well, quiet border, sage focus ring */
|
||
.input {
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-text-primary);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
font-size: var(--text-sm);
|
||
font-family: inherit;
|
||
letter-spacing: -0.005em;
|
||
outline: none;
|
||
width: 100%;
|
||
transition: border-color var(--duration-normal) var(--ease-spring),
|
||
box-shadow var(--duration-normal) var(--ease-spring),
|
||
background var(--duration-normal) var(--ease-spring);
|
||
}
|
||
.input::placeholder {
|
||
color: var(--color-text-muted);
|
||
opacity: 0.8;
|
||
}
|
||
.input:hover:not(:disabled):not(:focus) {
|
||
border-color: var(--color-border-strong);
|
||
}
|
||
.input:focus {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.input:disabled {
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-text-muted);
|
||
cursor: not-allowed;
|
||
opacity: 0.7;
|
||
}
|
||
select.input {
|
||
cursor: pointer;
|
||
padding-right: var(--spacing-xl);
|
||
}
|
||
|
||
.input-mono {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-sm);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.textarea {
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-text-primary);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
font-size: var(--text-sm);
|
||
font-family: inherit;
|
||
letter-spacing: -0.005em;
|
||
outline: none;
|
||
width: 100%;
|
||
resize: vertical;
|
||
min-height: 80px;
|
||
line-height: var(--leading-normal);
|
||
transition: border-color var(--duration-normal) var(--ease-spring),
|
||
box-shadow var(--duration-normal) var(--ease-spring);
|
||
}
|
||
.textarea::placeholder {
|
||
color: var(--color-text-muted);
|
||
opacity: 0.8;
|
||
}
|
||
.textarea:hover:not(:disabled):not(:focus) {
|
||
border-color: var(--color-border-strong);
|
||
}
|
||
.textarea:focus {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||
}
|
||
.textarea:disabled {
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-text-muted);
|
||
cursor: not-allowed;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* CodeMirror editor wrapper */
|
||
.code-editor-cm .cm-editor {
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.code-editor-cm .cm-editor.cm-focused {
|
||
border-color: var(--color-border-strong);
|
||
outline: none;
|
||
}
|
||
|
||
/* Form groups */
|
||
.form-group {
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: var(--spacing-xs);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Badges — sharp editorial rectangles, mono caps */
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 8px;
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.625rem;
|
||
font-weight: 500;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
line-height: 1.4;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.badge-success {
|
||
background: var(--color-success-light);
|
||
color: var(--color-success);
|
||
}
|
||
.badge-error {
|
||
background: var(--color-error-light);
|
||
color: var(--color-error);
|
||
}
|
||
.badge-info {
|
||
background: var(--color-info-light);
|
||
color: var(--color-info);
|
||
}
|
||
.badge-warning {
|
||
background: var(--color-warning-light);
|
||
color: var(--color-warning);
|
||
}
|
||
.badge-accent {
|
||
background: var(--color-accent-light);
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
/* badge-link marks a badge that navigates somewhere (e.g. the use-case
|
||
chips that open a playground page). Signals clickability the bare
|
||
colour variant doesn't: pointer cursor, and a hover state that lifts
|
||
the background/text so links read as interactive while non-link
|
||
badges (Embeddings, VAD, Score, ...) stay flat. */
|
||
.badge-link {
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
.badge-link:hover,
|
||
.badge-link:focus-visible {
|
||
background: var(--color-info);
|
||
color: var(--color-bg-primary);
|
||
box-shadow: 0 0 0 1px var(--color-info);
|
||
outline: none;
|
||
}
|
||
|
||
/* Horizontal row of badges used inside table cells — consistent spacing so
|
||
cells line up regardless of how many badges are present. */
|
||
.badge-row {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* Vertically stacked cell content (e.g. version + update chip + drift chip).
|
||
Keeps rows readable at scale without inline style={{...}} everywhere. */
|
||
.cell-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.cell-mono {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.cell-muted {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
}
|
||
|
||
.cell-subtle {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
font-weight: 400;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.cell-name {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-weight: 500;
|
||
}
|
||
.cell-name > i {
|
||
color: var(--color-accent);
|
||
font-size: var(--text-xs);
|
||
}
|
||
|
||
.row-actions {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
}
|
||
|
||
/* Softer delete button for dense tables — the destructive confirm dialog
|
||
already owns the "are you sure" affordance, so the button itself doesn't
|
||
need to scream. Keeps the delete red readable without dominating rows. */
|
||
.btn.btn-danger-ghost {
|
||
background: transparent;
|
||
color: var(--color-error);
|
||
border-color: transparent;
|
||
}
|
||
.btn.btn-danger-ghost:hover:not(:disabled) {
|
||
background: var(--color-error-light);
|
||
color: var(--color-error);
|
||
border-color: var(--color-error-light);
|
||
}
|
||
|
||
/* Small count pill used inside tabs ("(3) ↑ 2") so update counts are
|
||
glanceable without extra rows of UI. */
|
||
.tab-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
margin-left: 6px;
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-xs);
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
}
|
||
.tab-pill--warning {
|
||
background: var(--color-warning-light);
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
/* Stat cards — uniform-height cluster metrics for the Nodes dashboard.
|
||
Left accent bar ties the color to the metric's semantic (success/warning/
|
||
error/primary), icon chip sits top-right, value is left-aligned and
|
||
prominent so you can scan a row of cards without reading labels. */
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: var(--spacing-md);
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.stat-card {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md);
|
||
min-height: 96px;
|
||
background: var(--color-bg-raised, var(--color-bg-secondary));
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
transition: transform var(--duration-fast) var(--ease-default),
|
||
box-shadow var(--duration-fast) var(--ease-default),
|
||
border-color var(--duration-fast) var(--ease-default);
|
||
overflow: hidden;
|
||
}
|
||
.stat-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 3px;
|
||
background: var(--stat-accent, var(--color-border-subtle));
|
||
transition: background var(--duration-fast) var(--ease-default);
|
||
}
|
||
.stat-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-sm);
|
||
border-color: var(--color-border-strong);
|
||
}
|
||
|
||
.stat-card__body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.stat-card__label {
|
||
font-size: var(--text-xs);
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-muted);
|
||
white-space: normal;
|
||
line-height: 1.2;
|
||
}
|
||
.stat-card__value {
|
||
font-size: var(--text-2xl);
|
||
font-weight: 600;
|
||
font-family: var(--font-mono);
|
||
line-height: 1;
|
||
color: var(--color-text-primary);
|
||
word-break: break-word;
|
||
}
|
||
.stat-card__icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: var(--radius-md);
|
||
background: color-mix(in srgb, var(--stat-accent, var(--color-text-muted)) 12%, transparent);
|
||
color: var(--stat-accent, var(--color-text-muted));
|
||
font-size: var(--text-lg);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Subtle "Register a new worker" trigger replacing the broken-text chevron
|
||
link. Still opens the same hint card — just reads like a button now. */
|
||
.nodes-add-worker {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
background: transparent;
|
||
border: 1px dashed var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-sm);
|
||
font-family: inherit;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
margin-bottom: var(--spacing-md);
|
||
transition: background var(--duration-fast) var(--ease-default),
|
||
border-color var(--duration-fast) var(--ease-default),
|
||
color var(--duration-fast) var(--ease-default);
|
||
}
|
||
.nodes-add-worker:hover {
|
||
background: var(--color-bg-raised, var(--color-bg-secondary));
|
||
border-color: var(--color-border-strong);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
/* Shared FilterBar layout — search strip + chip row + toggle strip. Lives
|
||
outside the .filter-bar chip row so the padding and wrapping behavior is
|
||
consistent between the Backends gallery and the System tabs. */
|
||
.filter-bar-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.filter-bar-group__search {
|
||
min-width: 200px;
|
||
flex: 1;
|
||
}
|
||
.filter-bar-group__row {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.filter-bar-group__right {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
padding-left: var(--spacing-md);
|
||
border-left: 1px solid var(--color-border-subtle);
|
||
margin-left: auto;
|
||
}
|
||
.filter-bar-group__toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
/* Models gallery filter block. Three bands (query / taxonomy / refinements)
|
||
stacked by .filter-bar-group. The refinements live in their own band so the
|
||
chip row's wrap height can never displace them. */
|
||
.models-filters__query {
|
||
flex-wrap: nowrap;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.models-filters__backend {
|
||
flex: 0 0 auto;
|
||
min-width: 0;
|
||
}
|
||
/* .filter-bar carries its own bottom margin for standalone use; inside the
|
||
group the parent's gap owns the rhythm. */
|
||
.models-filters > .filter-bar {
|
||
margin-bottom: 0;
|
||
}
|
||
.models-filters__refine {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-sm) var(--spacing-lg);
|
||
padding-top: var(--spacing-sm);
|
||
border-top: 1px solid var(--color-border-subtle);
|
||
}
|
||
/* Both refinements read as one secondary group: same size, same colour and the
|
||
same icon-then-label rhythm as .filter-bar-group__toggle. */
|
||
.models-filters__context {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.models-filters__context > label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
}
|
||
.models-filters__context input[type='range'] {
|
||
width: 140px;
|
||
accent-color: var(--color-primary);
|
||
cursor: pointer;
|
||
}
|
||
.models-filters__context-value {
|
||
font-weight: var(--font-weight-medium);
|
||
min-width: 3em;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
/* Search and backend select each take a full row rather than squeezing the
|
||
input below a usable width. */
|
||
.models-filters__query {
|
||
flex-wrap: wrap;
|
||
}
|
||
.models-filters__backend {
|
||
flex: 1 1 100%;
|
||
}
|
||
}
|
||
|
||
.filter-btn__count {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: 6px;
|
||
min-width: 18px;
|
||
padding: 0 5px;
|
||
background: color-mix(in srgb, currentColor 18%, transparent);
|
||
border-radius: var(--radius-full);
|
||
font-size: 0.625rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Popover — floating surface anchored to a trigger element. Uses the .card
|
||
base so theming is free, adds z-index + fixed-position + scroll cap so it
|
||
behaves on tables with many rows. Kept deliberately unstyled beyond that
|
||
— content is expected to provide its own header/body structure. */
|
||
.popover {
|
||
position: fixed;
|
||
z-index: 200;
|
||
min-width: 260px;
|
||
max-width: min(420px, 95vw);
|
||
max-height: min(420px, 70vh);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0; /* sections provide their own padding */
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-lg);
|
||
animation: popoverIn var(--duration-fast) var(--ease-default);
|
||
}
|
||
|
||
@keyframes popoverIn {
|
||
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||
}
|
||
|
||
.popover__header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
.popover__scroll {
|
||
overflow: auto;
|
||
padding: 0;
|
||
}
|
||
|
||
.popover__table {
|
||
margin: 0;
|
||
width: 100%;
|
||
}
|
||
.popover__table th {
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--color-bg-raised, var(--color-bg-secondary));
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Inline-table chip trigger — looks like a badge but is a button (cursor,
|
||
focus ring inherited from global :focus-visible). */
|
||
.chip-trigger {
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.chip-trigger:hover {
|
||
filter: brightness(1.08);
|
||
}
|
||
|
||
/* Truncate + ellipsize a long cell (e.g. OCI digest) without breaking the
|
||
table layout. Tooltip preserves the full value. */
|
||
.cell-truncate {
|
||
max-width: 160px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Compact empty-state used inside expanded drawer sections (e.g. "No
|
||
models loaded on this node"). Dimmer than the page-level .empty-state
|
||
because it lives inside another container and shouldn't compete with
|
||
the row's primary content. */
|
||
.drawer-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px dashed var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.drawer-empty > i {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* Small caps eyebrow inside the drawer's "Manage" disclosure. Replaces the
|
||
h4 sub-headings that used to stack inside the drawer — at this depth, an
|
||
eyebrow keeps the typographic hierarchy from feeling parallel to the
|
||
page-level h1/h2 stack. */
|
||
.drawer-eyebrow {
|
||
font-size: 0.6875rem;
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-muted);
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
|
||
/* "Manage" disclosure inside the node drawer. The chevron rotates with the
|
||
open state so the affordance reads as an accordion, not a link. */
|
||
.node-manage > summary {
|
||
user-select: none;
|
||
outline: none;
|
||
}
|
||
.node-manage > summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
.node-manage > summary:focus-visible {
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: 2px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.node-manage__chevron {
|
||
font-size: 0.625rem;
|
||
transition: transform var(--duration-fast) ease-out;
|
||
}
|
||
.node-manage[open] > summary .node-manage__chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
/* Node-status indicator — replaces the tiny bullet with a proper LED-style
|
||
dot next to a bold status label. Colors are applied inline from statusConfig
|
||
so one primitive handles healthy/unhealthy/draining/pending in one shape. */
|
||
.node-status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: var(--text-sm);
|
||
font-weight: 600;
|
||
}
|
||
.node-status__dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 15%, transparent);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Row-chevron cell — small 20px toggle used in table rows that expand.
|
||
The row itself is still clickable; the chevron provides the visible
|
||
affordance users were missing. */
|
||
.row-chevron {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
transition: transform var(--duration-fast) var(--ease-default);
|
||
}
|
||
.row-chevron.is-expanded {
|
||
transform: rotate(90deg);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
/* Upgrade banner — the yellow strip operators see when updates are available.
|
||
Mirrors the gallery so both pages speak the same visual language. */
|
||
.upgrade-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
margin-bottom: var(--spacing-md);
|
||
background: var(--color-warning-light);
|
||
border: 1px solid var(--color-warning);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-warning);
|
||
}
|
||
.upgrade-banner__text {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-weight: 500;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.upgrade-banner__actions {
|
||
display: inline-flex;
|
||
gap: var(--spacing-xs);
|
||
align-items: center;
|
||
}
|
||
|
||
/* Tabs */
|
||
.tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.tab {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all var(--duration-fast);
|
||
}
|
||
|
||
.tab:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.tab-active {
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
}
|
||
|
||
/* Tables */
|
||
.table-container {
|
||
overflow-x: auto;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
/* Prevent flexbox container from stretching due to large inner HTML tables */
|
||
width: 1px;
|
||
min-width: 100%;
|
||
}
|
||
|
||
/* ResponsiveTable: stack dense tables into label/value cards on narrow screens
|
||
instead of a sideways scroll. Labels come from data-label (mirrored from the
|
||
<thead> by the ResponsiveTable component). */
|
||
@media (max-width: 640px) {
|
||
/* Direct-child selectors only: a nested table inside a cell renders normally.
|
||
min-width override defeats any inline min-width set for the desktop layout. */
|
||
.table--responsive { border: none; min-width: 0 !important; }
|
||
.table--responsive > thead { display: none; }
|
||
.table--responsive > tbody > tr {
|
||
display: block;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface-raised);
|
||
margin: var(--spacing-sm);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
}
|
||
.table--responsive > tbody > tr > td {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
border: none;
|
||
padding: var(--spacing-xs) 0;
|
||
text-align: right;
|
||
}
|
||
.table--responsive > tbody > tr > td[data-label]::before {
|
||
content: attr(data-label);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-muted);
|
||
text-align: left;
|
||
margin-right: auto;
|
||
}
|
||
}
|
||
|
||
.table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.table th {
|
||
text-align: left;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-secondary);
|
||
font-weight: 500;
|
||
font-size: 0.8125rem;
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
}
|
||
|
||
.table td {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
color: var(--color-text-primary);
|
||
transition: background var(--duration-fast) var(--ease-default);
|
||
}
|
||
|
||
.table tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.table tr:hover td {
|
||
background: var(--color-surface-hover);
|
||
}
|
||
|
||
/* Model checkbox list */
|
||
.model-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
max-height: 200px;
|
||
overflow: auto;
|
||
padding: var(--spacing-xs);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
|
||
.model-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.model-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.model-list::-webkit-scrollbar-thumb {
|
||
background: var(--color-border-default);
|
||
border-radius: var(--radius-full);
|
||
}
|
||
|
||
.model-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: 6px var(--spacing-sm);
|
||
cursor: pointer;
|
||
border-radius: var(--radius-sm);
|
||
transition: background var(--duration-fast) var(--ease-default);
|
||
user-select: none;
|
||
}
|
||
|
||
.model-item:hover {
|
||
background: var(--color-primary-light);
|
||
}
|
||
|
||
.model-item.model-item-checked {
|
||
background: var(--color-primary-light);
|
||
}
|
||
|
||
.model-item input[type="checkbox"] {
|
||
display: none;
|
||
}
|
||
|
||
.model-item-check {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: var(--radius-sm);
|
||
border: 2px solid var(--color-border-default);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
transition: all var(--duration-fast) var(--ease-default);
|
||
background: transparent;
|
||
}
|
||
|
||
.model-item:hover .model-item-check {
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.model-item-checked .model-item-check {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 1px var(--color-primary-light);
|
||
}
|
||
|
||
.model-item-checked .model-item-check i {
|
||
color: white;
|
||
font-size: 10px;
|
||
animation: checkPop var(--duration-fast) var(--ease-default);
|
||
}
|
||
|
||
@keyframes checkPop {
|
||
0% { transform: scale(0); }
|
||
60% { transform: scale(1.2); }
|
||
100% { transform: scale(1); }
|
||
}
|
||
|
||
.model-item-name {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.model-item-checked .model-item-name {
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
/* Collapsible */
|
||
.collapsible-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) 0;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
user-select: none;
|
||
}
|
||
|
||
button.collapsible-header {
|
||
width: 100%;
|
||
border: 0;
|
||
background: transparent;
|
||
font-family: inherit;
|
||
text-align: left;
|
||
}
|
||
|
||
button.collapsible-header:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
button.collapsible-header:focus-visible {
|
||
border-radius: var(--radius-sm);
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.collapsible-header i {
|
||
transition: transform var(--duration-fast);
|
||
}
|
||
|
||
.collapsible-header.open i {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
/* Search bar */
|
||
.search-bar {
|
||
position: relative;
|
||
}
|
||
|
||
.search-bar .search-icon {
|
||
position: absolute;
|
||
left: var(--spacing-sm);
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.search-bar .input {
|
||
padding-left: 2rem;
|
||
}
|
||
|
||
/* Pagination */
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-xs);
|
||
margin-top: var(--spacing-lg);
|
||
}
|
||
|
||
.pagination-btn {
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
font-size: 0.8125rem;
|
||
transition: all var(--duration-fast);
|
||
}
|
||
|
||
.pagination-btn:hover:not(:disabled) {
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.pagination-btn.active {
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.pagination-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Filter buttons */
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
flex-wrap: wrap;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 6px var(--spacing-md);
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text-secondary);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-full);
|
||
cursor: pointer;
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
font-family: inherit;
|
||
box-shadow: var(--shadow-subtle);
|
||
transition: all var(--duration-fast);
|
||
}
|
||
|
||
.filter-btn:hover {
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.filter-btn.active {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-primary);
|
||
border-color: var(--color-primary-border);
|
||
}
|
||
|
||
/* The global :focus-visible ring is wrapped in :where(), so it carries the
|
||
specificity of a bare :focus-visible - the same (0,1,0) as .filter-btn, which
|
||
is declared later and therefore won with its resting shadow. Chips were left
|
||
with no visible keyboard focus indicator at all; this restates the ring where
|
||
it outranks both the resting and the hover shadow. */
|
||
.filter-btn:focus-visible {
|
||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
/* A chip is disabled when the selected backend cannot serve that use case. It
|
||
keeps its resting colours so the row still reads as a set, dimmed enough to
|
||
register as out of play. */
|
||
.filter-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
}
|
||
.filter-btn:disabled:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
color: var(--color-text-secondary);
|
||
border-color: var(--color-border-default);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.filter-btn {
|
||
transition: none;
|
||
}
|
||
.filter-btn:hover {
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
/* Login page */
|
||
.login-page {
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
background: var(--color-bg-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: var(--spacing-xl);
|
||
}
|
||
|
||
.login-card {
|
||
width: 100%;
|
||
max-width: 400px;
|
||
padding: var(--spacing-xl);
|
||
}
|
||
|
||
.login-header {
|
||
text-align: center;
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.login-logo {
|
||
width: 56px;
|
||
height: 56px;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.login-title {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
margin-bottom: var(--spacing-xs);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.login-tagline {
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.9375rem;
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
|
||
.login-subtitle {
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.login-alert {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-radius: var(--radius-md);
|
||
font-size: 0.8125rem;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.login-alert-error {
|
||
background: var(--color-error-light);
|
||
color: var(--color-error);
|
||
border: 1px solid var(--color-error-border);
|
||
}
|
||
|
||
.login-alert-success {
|
||
background: var(--color-success-light);
|
||
color: var(--color-success);
|
||
border: 1px solid var(--color-success-border);
|
||
}
|
||
|
||
.login-divider {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
margin: var(--spacing-lg) 0;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.login-divider::before,
|
||
.login-divider::after {
|
||
content: '';
|
||
flex: 1;
|
||
height: 1px;
|
||
background: var(--color-border-subtle);
|
||
}
|
||
|
||
.login-footer {
|
||
text-align: center;
|
||
margin-top: var(--spacing-md);
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.login-link {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-primary);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
font: inherit;
|
||
}
|
||
|
||
.login-link:hover {
|
||
color: var(--color-primary-hover);
|
||
}
|
||
|
||
.login-token-toggle {
|
||
margin-top: var(--spacing-lg);
|
||
text-align: center;
|
||
}
|
||
|
||
.login-token-toggle > button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
padding: 0;
|
||
font: inherit;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.login-token-toggle > button:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.login-token-form {
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
|
||
/* Empty state */
|
||
/* Empty state — editorial: eyebrow rule + large mono headline + lede.
|
||
Existing pages pass `icon`, `title`, `text` children into .empty-state as
|
||
nested elements; the rules below re-style each without JSX changes. */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-md);
|
||
text-align: left;
|
||
padding: var(--spacing-3xl) var(--spacing-xl);
|
||
max-width: 640px;
|
||
margin: 0 auto;
|
||
animation: fadeIn var(--duration-slow) var(--ease-spring);
|
||
}
|
||
|
||
/* Center the column within its parent without shrinking type. */
|
||
.empty-state > * { max-width: 100%; }
|
||
|
||
.empty-state-icon {
|
||
font-size: 1.5rem;
|
||
color: var(--color-primary);
|
||
opacity: 0.7;
|
||
margin: 0;
|
||
}
|
||
|
||
.empty-state-title {
|
||
font-family: var(--font-sans);
|
||
font-size: clamp(1.5rem, 3vw, var(--text-3xl));
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: -0.02em;
|
||
color: var(--color-text-primary);
|
||
margin: 0;
|
||
line-height: 1.15;
|
||
}
|
||
|
||
.empty-state-text {
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-normal);
|
||
max-width: 52ch;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Secondary to .empty-state-text: a hint about a default that may be narrowing
|
||
the result, so it must not compete with the reason stated above it. */
|
||
.empty-state-hint {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
line-height: var(--leading-normal);
|
||
max-width: 52ch;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Opt-in editorial sub-parts — pages can adopt these class names over time */
|
||
.empty-state__eyebrow {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.625rem;
|
||
letter-spacing: 0.24em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.empty-state__eyebrow::before {
|
||
content: "";
|
||
display: block;
|
||
width: 24px;
|
||
height: 1px;
|
||
background: var(--color-border-strong);
|
||
}
|
||
|
||
.empty-state__actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
flex-wrap: wrap;
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
|
||
/* Animations */
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from { opacity: 0; transform: translateX(12px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
|
||
@keyframes toastSlideIn {
|
||
from { opacity: 0; transform: translateX(12px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes popIn {
|
||
from { opacity: 0; transform: scale(0.96) translateY(4px); }
|
||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
|
||
@keyframes attentionPulse {
|
||
0%, 100% { opacity: 0.65; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
@keyframes opsBarBreathe {
|
||
0%, 100% { opacity: 0.85; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
@keyframes breathingRing {
|
||
0%, 100% { box-shadow: inset 0 0 0 1.5px var(--color-primary-light); }
|
||
50% { box-shadow: inset 0 0 0 2px var(--color-primary); }
|
||
}
|
||
|
||
@keyframes messageSlideIn {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
@keyframes dropdownIn {
|
||
from { opacity: 0; transform: translateY(-4px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* Page route transitions */
|
||
.page-transition {
|
||
animation: pageReveal var(--duration-reveal) var(--ease-reveal) both;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
@keyframes pageReveal {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* Orchestrated child stagger - opt-in via .reveal-stagger on a container.
|
||
Each direct child reveals with an incremental delay driven by --reveal-index. */
|
||
.reveal-stagger > * {
|
||
animation: pageReveal var(--duration-reveal) var(--ease-reveal) both;
|
||
animation-delay: calc(var(--reveal-index, 0) * 60ms);
|
||
}
|
||
|
||
/* Chat-specific styles */
|
||
.chat-layout {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-sidebar {
|
||
width: 260px;
|
||
background: var(--color-bg-secondary);
|
||
border-right: 1px solid var(--color-border-subtle);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
transition: width 200ms ease, opacity 150ms ease;
|
||
}
|
||
.chat-sidebar.hidden {
|
||
width: 0;
|
||
border-right: none;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.chat-sidebar-header {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
align-items: center;
|
||
}
|
||
|
||
.chat-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--spacing-xs);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--color-border-subtle) transparent;
|
||
}
|
||
.chat-list::-webkit-scrollbar { width: 4px; }
|
||
.chat-list::-webkit-scrollbar-track { background: transparent; }
|
||
.chat-list::-webkit-scrollbar-thumb {
|
||
background: var(--color-border-subtle);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.chat-list-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
border-left: 3px solid transparent;
|
||
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.chat-list-item:hover:not(.active) {
|
||
background: var(--color-surface-hover);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.chat-list-item.active {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-primary);
|
||
border-left-color: var(--color-primary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
.chat-list-item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.chat-list-item-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.chat-list-item-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 500;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.chat-list-item-time {
|
||
font-size: 0.625rem;
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chat-list-item-preview {
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.chat-list-item-delete {
|
||
opacity: 0;
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 2px;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.chat-list-item:hover .chat-list-item-delete {
|
||
opacity: 1;
|
||
}
|
||
|
||
.chat-main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--color-border-subtle) transparent;
|
||
transition: padding-top var(--duration-normal) var(--ease-default);
|
||
}
|
||
.chat-messages::-webkit-scrollbar { width: 6px; }
|
||
.chat-messages::-webkit-scrollbar-track { background: transparent; }
|
||
|
||
/* Floating "jump to latest" pill, shown when scrolled away from the bottom. */
|
||
.chat-jump-latest {
|
||
position: sticky;
|
||
bottom: 12px;
|
||
align-self: center;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text-primary);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-full);
|
||
box-shadow: var(--shadow-md);
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
cursor: pointer;
|
||
z-index: 5;
|
||
animation: pageReveal var(--duration-fast) var(--ease-default) both;
|
||
}
|
||
.chat-jump-latest:hover { border-color: var(--color-primary-border); color: var(--color-primary); }
|
||
.chat-messages::-webkit-scrollbar-thumb {
|
||
background: var(--color-border-subtle);
|
||
border-radius: 3px;
|
||
}
|
||
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||
background: var(--color-border-default);
|
||
}
|
||
|
||
.chat-message {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
max-width: 88%;
|
||
min-width: 0;
|
||
animation: messageSlideIn 250ms ease-out;
|
||
}
|
||
|
||
.chat-message-user {
|
||
align-self: flex-end;
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.chat-message-assistant {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.chat-message-avatar {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.75rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chat-message-user .chat-message-avatar {
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
}
|
||
|
||
/* Assistant gets the left-border accent on the bubble; the avatar is
|
||
visual noise once that accent is in place. */
|
||
.chat-message-assistant .chat-message-avatar {
|
||
display: none;
|
||
}
|
||
|
||
.chat-message-bubble {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.chat-message-model {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.625rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.14em;
|
||
padding-left: 2px;
|
||
}
|
||
|
||
.chat-message-content {
|
||
background: transparent;
|
||
border: none;
|
||
border-left: 2px solid var(--color-border-strong);
|
||
border-radius: 0;
|
||
padding: 0 var(--spacing-md);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-normal);
|
||
word-break: break-word;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.chat-message-user .chat-message-content {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-text-primary);
|
||
border: 1px solid var(--color-primary-border);
|
||
border-radius: 16px 4px 16px 16px;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
.chat-message-content pre {
|
||
background: var(--color-bg-primary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-sm);
|
||
overflow-x: auto;
|
||
margin: var(--spacing-sm) 0;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
/* Code block with a header (language + copy), injected by enhanceCodeBlocks. */
|
||
.code-block {
|
||
margin: var(--spacing-sm) 0;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
background: var(--color-bg-primary);
|
||
}
|
||
.code-block__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 2px var(--spacing-sm);
|
||
background: var(--color-surface-sunken);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
}
|
||
.code-block__lang {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.code-block .code-copy-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 4px 6px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: var(--text-sm);
|
||
transition: color var(--duration-fast) var(--ease-default),
|
||
background var(--duration-fast) var(--ease-default);
|
||
}
|
||
.code-block .code-copy-btn:hover { color: var(--color-text-primary); background: var(--color-bg-hover); }
|
||
.code-block .code-copy-btn--ok { color: var(--color-success); }
|
||
/* Inner pre sits flush inside the framed block. */
|
||
.code-block pre {
|
||
margin: 0 !important;
|
||
border: none !important;
|
||
border-radius: 0 !important;
|
||
}
|
||
|
||
.chat-message-user .chat-message-content pre {
|
||
background: var(--color-surface-sunken);
|
||
border-color: var(--color-border-subtle);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.chat-message-user .chat-message-content code {
|
||
color: var(--color-text-primary);
|
||
}
|
||
.chat-message-user .chat-message-content a {
|
||
color: var(--color-primary);
|
||
text-decoration: underline;
|
||
text-underline-offset: 2px;
|
||
}
|
||
|
||
.chat-message-content code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.chat-message-content p {
|
||
margin: var(--spacing-xs) 0;
|
||
}
|
||
|
||
.chat-message-content p:first-child {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.chat-message-content p:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* Message action buttons */
|
||
.chat-message-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
opacity: 0;
|
||
transition: opacity 150ms;
|
||
padding-left: 2px;
|
||
}
|
||
.chat-message:hover .chat-message-actions {
|
||
opacity: 1;
|
||
}
|
||
.chat-message-actions button {
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 3px 6px;
|
||
font-size: 0.6875rem;
|
||
border-radius: var(--radius-sm);
|
||
transition: all 150ms;
|
||
}
|
||
.chat-message-actions button:hover {
|
||
color: var(--color-primary);
|
||
border-color: var(--color-primary-border);
|
||
background: var(--color-primary-light);
|
||
}
|
||
|
||
.chat-message-system {
|
||
align-self: center;
|
||
max-width: 90%;
|
||
}
|
||
.chat-message-system .chat-message-bubble {
|
||
color: var(--color-text-muted);
|
||
background: transparent;
|
||
border: none;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.01em;
|
||
padding: 2px 0;
|
||
}
|
||
.chat-message-system .chat-message-content {
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 0;
|
||
padding: 2px var(--spacing-sm);
|
||
font-size: 0.7rem;
|
||
line-height: 1.4;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.chat-message-timestamp {
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.chat-input-area {
|
||
padding: var(--spacing-xs) var(--spacing-md) var(--spacing-sm);
|
||
background: var(--color-bg-secondary);
|
||
border-top: 1px solid var(--color-border-subtle);
|
||
}
|
||
|
||
.chat-input-wrapper {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-xs);
|
||
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
|
||
}
|
||
|
||
/* Mode chips (Canvas, MCP) — render at the start of the input wrapper so
|
||
the user sees what's armed for the next message. Compact, low-noise
|
||
when off; subtly highlighted when on. The MCP trigger inherits the
|
||
same look via the nested-selector overrides below. */
|
||
.chat-input-modes {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-mode-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
background: transparent;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: 999px;
|
||
color: var(--color-text-muted);
|
||
font-family: inherit;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background var(--duration-fast), border-color var(--duration-fast), color var(--duration-fast);
|
||
}
|
||
.chat-mode-chip:hover {
|
||
background: var(--color-bg-hover);
|
||
border-color: var(--color-border-default);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.chat-mode-chip:focus-visible {
|
||
outline: 2px solid var(--color-focus-ring);
|
||
outline-offset: 2px;
|
||
}
|
||
.chat-mode-chip-on,
|
||
.chat-mode-chip-on:hover {
|
||
background: var(--color-primary-light);
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
}
|
||
.chat-mode-chip i {
|
||
font-size: 0.7rem;
|
||
}
|
||
.chat-mode-chip-label {
|
||
line-height: 1;
|
||
}
|
||
.chat-mode-chip-count {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 18px;
|
||
height: 16px;
|
||
padding: 0 5px;
|
||
margin-left: 2px;
|
||
border-radius: 999px;
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
}
|
||
.chat-mode-chip-count:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
/* MCP popover sits above the chip when it's anchored to the input row
|
||
(otherwise it would drop off-screen). */
|
||
.chat-input-modes .chat-mcp-dropdown-menu {
|
||
top: auto;
|
||
bottom: calc(100% + 4px);
|
||
}
|
||
|
||
/* When the MCP dropdown lives inside the modes row, make its trigger
|
||
match the chip aesthetic without altering the component itself. */
|
||
.chat-input-modes .chat-mcp-dropdown > .btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
background: transparent;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: 999px;
|
||
color: var(--color-text-muted);
|
||
font-family: inherit;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
box-shadow: none;
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
transition: background var(--duration-fast), border-color var(--duration-fast), color var(--duration-fast);
|
||
}
|
||
.chat-input-modes .chat-mcp-dropdown > .btn:hover {
|
||
background: var(--color-bg-hover);
|
||
border-color: var(--color-border-default);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.chat-input-modes .chat-mcp-dropdown > .btn .chat-mcp-badge {
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
}
|
||
/* Active state when at least one MCP source is selected — UnifiedMCPDropdown
|
||
exposes its count via .chat-mcp-badge, so we use :has() to lift the
|
||
chip into the "on" look. Falls back to the neutral chip look on browsers
|
||
without :has(). */
|
||
.chat-input-modes .chat-mcp-dropdown > .btn:has(.chat-mcp-badge),
|
||
.chat-input-modes .chat-mcp-dropdown > .btn:has(.chat-mcp-badge):hover {
|
||
background: var(--color-primary-light);
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.chat-mode-chip-label { display: none; }
|
||
.chat-mode-chip,
|
||
.chat-input-modes .chat-mcp-dropdown > .btn {
|
||
padding: 0 8px;
|
||
}
|
||
}
|
||
.chat-input-wrapper:focus-within {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 1px var(--color-primary-border);
|
||
}
|
||
|
||
.chat-attach-btn {
|
||
flex-shrink: 0;
|
||
border: none !important;
|
||
background: transparent !important;
|
||
color: var(--color-text-muted) !important;
|
||
padding: var(--spacing-xs) !important;
|
||
}
|
||
.chat-attach-btn:hover {
|
||
color: var(--color-primary) !important;
|
||
}
|
||
|
||
.chat-input {
|
||
flex: 1;
|
||
background: transparent;
|
||
color: var(--color-text-primary);
|
||
border: none;
|
||
padding: 6px var(--spacing-sm);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
resize: none;
|
||
min-height: 32px;
|
||
max-height: 200px;
|
||
line-height: 1.5;
|
||
overflow-y: auto;
|
||
/* Modern auto-grow — JS auto-grow stays as a fallback for older engines. */
|
||
field-sizing: content;
|
||
}
|
||
|
||
.chat-input::placeholder {
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.chat-send-btn {
|
||
padding: var(--spacing-xs);
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
align-self: flex-start;
|
||
transition: background var(--duration-fast), transform var(--duration-fast);
|
||
}
|
||
|
||
.chat-send-btn:hover:not(:disabled) {
|
||
background: var(--color-primary-hover);
|
||
}
|
||
|
||
.chat-send-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
.chat-send-btn:active:not(:disabled) {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
.chat-stop-btn {
|
||
padding: var(--spacing-xs);
|
||
background: var(--color-error);
|
||
color: white;
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
align-self: flex-start;
|
||
transition: background var(--duration-fast);
|
||
}
|
||
.chat-stop-btn:hover {
|
||
background: var(--color-error-hover, #dc2626);
|
||
}
|
||
|
||
.chat-token-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-xs) var(--spacing-lg);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.chat-streaming-cursor::after {
|
||
content: '';
|
||
display: inline-block;
|
||
width: 6px;
|
||
height: 14px;
|
||
background: var(--color-primary);
|
||
margin-left: 2px;
|
||
animation: pulse 1s infinite;
|
||
vertical-align: text-bottom;
|
||
}
|
||
|
||
/* Inline streaming speed indicator */
|
||
.chat-streaming-speed {
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-muted);
|
||
padding-top: var(--spacing-xs);
|
||
font-family: var(--font-mono);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* Thinking dots animation */
|
||
.chat-thinking-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 24px;
|
||
}
|
||
.chat-thinking-dots {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
}
|
||
.chat-thinking-dots span {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--color-text-muted);
|
||
animation: thinkingBounce 1.4s infinite ease-in-out both;
|
||
}
|
||
.chat-thinking-dots span:nth-child(1) { animation-delay: -0.32s; }
|
||
.chat-thinking-dots span:nth-child(2) { animation-delay: -0.16s; }
|
||
.chat-thinking-dots span:nth-child(3) { animation-delay: 0s; }
|
||
@keyframes thinkingBounce {
|
||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||
40% { transform: scale(1); opacity: 1; }
|
||
}
|
||
|
||
/* Staging progress indicator (replaces thinking dots during model transfer) */
|
||
.chat-staging-progress {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 200px;
|
||
max-width: 320px;
|
||
}
|
||
.chat-staging-label {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.chat-staging-label i {
|
||
color: var(--color-primary);
|
||
}
|
||
.chat-staging-detail {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.chat-staging-bar-container {
|
||
flex: 1;
|
||
height: 4px;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
}
|
||
.chat-staging-bar {
|
||
height: 100%;
|
||
background: var(--color-primary);
|
||
border-radius: 2px;
|
||
transition: width 300ms ease;
|
||
}
|
||
.chat-staging-pct {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
min-width: 32px;
|
||
text-align: right;
|
||
}
|
||
.chat-staging-file {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Message completion flash — briefly highlights the last assistant bubble when streaming ends */
|
||
@keyframes messageCompletionFlash {
|
||
0% { box-shadow: 0 0 0 0 var(--color-primary-border); }
|
||
40% { box-shadow: 0 0 0 3px var(--color-primary-light); }
|
||
100% { box-shadow: 0 0 0 0 transparent; }
|
||
}
|
||
.chat-message-new .chat-message-content {
|
||
animation: messageCompletionFlash 600ms ease-out;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.chat-message { animation: none; }
|
||
.chat-message-new .chat-message-content { animation: none; }
|
||
.chat-thinking-dots span { animation: none; opacity: 0.7; }
|
||
}
|
||
|
||
/* Chat empty state */
|
||
.chat-empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 1;
|
||
padding: var(--spacing-lg) var(--spacing-md);
|
||
text-align: center;
|
||
min-height: 240px;
|
||
gap: var(--spacing-md);
|
||
}
|
||
.chat-empty-state > * { margin-top: 0; margin-bottom: 0; }
|
||
.chat-empty-icon {
|
||
font-size: 3rem;
|
||
color: var(--color-border-default);
|
||
margin-bottom: var(--spacing-lg);
|
||
width: 80px;
|
||
height: 80px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: var(--color-bg-tertiary);
|
||
}
|
||
.chat-empty-title {
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
margin: 0 0 var(--spacing-xs);
|
||
}
|
||
.chat-empty-text {
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-secondary);
|
||
margin: 0 0 var(--spacing-lg);
|
||
max-width: 400px;
|
||
}
|
||
.chat-empty-suggestions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-sm);
|
||
justify-content: center;
|
||
margin-bottom: var(--spacing-lg);
|
||
max-width: 520px;
|
||
}
|
||
.chat-empty-suggestion {
|
||
padding: 8px var(--spacing-md);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
font-family: inherit;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
box-shadow: var(--shadow-subtle), var(--shadow-inset-top);
|
||
transition: all var(--duration-fast);
|
||
}
|
||
.chat-empty-suggestion:hover {
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
background: var(--color-surface-raised);
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-sm), var(--shadow-inset-top);
|
||
}
|
||
.chat-empty-hints {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.chat-empty-hints span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.chat-empty-hints i {
|
||
font-size: 0.625rem;
|
||
}
|
||
|
||
/* Recent strip on the empty state — replaces the old persistent
|
||
conversation sidebar. Visible only while messages.length === 0. */
|
||
.chat-recent-strip {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
width: 100%;
|
||
max-width: 720px;
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
.chat-recent-strip-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--color-text-muted);
|
||
text-align: left;
|
||
}
|
||
.chat-recent-strip-kbd {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.65rem;
|
||
padding: 1px 5px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
}
|
||
.chat-recent-strip-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.chat-recent-strip-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
font-family: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition: border-color var(--duration-fast), background var(--duration-fast), transform var(--duration-fast);
|
||
}
|
||
.chat-recent-strip-item:hover {
|
||
border-color: var(--color-primary-border);
|
||
background: var(--color-surface-raised);
|
||
transform: translateY(-1px);
|
||
}
|
||
.chat-recent-strip-item-name {
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chat-recent-strip-item-preview {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chat-recent-strip-item-time {
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-tertiary);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* ChatsMenu — popover replacing the old persistent history sidebar. */
|
||
.chats-menu {
|
||
position: relative;
|
||
display: inline-block;
|
||
flex-shrink: 0;
|
||
}
|
||
.chats-menu-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.chats-menu-trigger.active {
|
||
background: var(--color-primary-light);
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
}
|
||
.chats-menu-trigger-label {
|
||
font-weight: 500;
|
||
}
|
||
.chats-menu-trigger-kbd {
|
||
display: none;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.65rem;
|
||
padding: 1px 5px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border-subtle);
|
||
}
|
||
@media (min-width: 640px) {
|
||
.chats-menu-trigger-kbd { display: inline-block; }
|
||
}
|
||
.chats-menu-popover {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
left: 0;
|
||
z-index: 100;
|
||
width: 320px;
|
||
max-width: calc(100vw - var(--spacing-md) * 2);
|
||
background: var(--color-bg-primary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: min(70vh, 520px);
|
||
animation: dropdownIn 120ms ease-out;
|
||
}
|
||
.chats-menu-search {
|
||
position: relative;
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
}
|
||
.chats-menu-search-icon {
|
||
position: absolute;
|
||
left: 16px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.75rem;
|
||
pointer-events: none;
|
||
}
|
||
.chats-menu-search-input {
|
||
width: 100%;
|
||
padding: 6px 26px 6px 28px;
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-primary);
|
||
outline: none;
|
||
transition: border-color var(--duration-fast);
|
||
}
|
||
.chats-menu-search-input:focus {
|
||
border-color: var(--color-primary-border);
|
||
}
|
||
.chats-menu-search-clear {
|
||
position: absolute;
|
||
right: 14px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
padding: 2px;
|
||
}
|
||
.chats-menu-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--spacing-xs);
|
||
scrollbar-width: thin;
|
||
}
|
||
.chats-menu-empty {
|
||
padding: var(--spacing-md);
|
||
text-align: center;
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.chats-menu-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
border-left: 2px solid transparent;
|
||
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
|
||
}
|
||
.chats-menu-item.highlighted {
|
||
background: var(--color-bg-hover);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.chats-menu-item.active {
|
||
color: var(--color-primary);
|
||
border-left-color: var(--color-primary);
|
||
background: var(--color-primary-light);
|
||
}
|
||
.chats-menu-item-icon {
|
||
font-size: 0.7rem;
|
||
margin-top: 4px;
|
||
color: var(--color-text-tertiary);
|
||
flex-shrink: 0;
|
||
}
|
||
.chats-menu-item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.chats-menu-item-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.chats-menu-item-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 500;
|
||
font-size: 0.8125rem;
|
||
}
|
||
.chats-menu-item-spin {
|
||
margin-right: 6px;
|
||
font-size: 0.7rem;
|
||
opacity: 0.7;
|
||
}
|
||
.chats-menu-item-time {
|
||
font-size: 0.65rem;
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.chats-menu-item-preview {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
line-height: 1.3;
|
||
}
|
||
.chats-menu-item-rename {
|
||
flex: 1;
|
||
font-size: 0.8125rem;
|
||
padding: 2px 4px;
|
||
}
|
||
.chats-menu-item-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
opacity: 0;
|
||
transition: opacity var(--duration-fast);
|
||
flex-shrink: 0;
|
||
}
|
||
.chats-menu-item:hover .chats-menu-item-actions,
|
||
.chats-menu-item.highlighted .chats-menu-item-actions {
|
||
opacity: 1;
|
||
}
|
||
.chats-menu-item-actions button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 2px 4px;
|
||
font-size: 0.7rem;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.chats-menu-item-actions button:hover {
|
||
color: var(--color-text-primary);
|
||
background: var(--color-bg-tertiary);
|
||
}
|
||
.chats-menu-item-actions .chats-menu-item-delete:hover {
|
||
color: var(--color-error);
|
||
}
|
||
.chats-menu-footer {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-xs);
|
||
border-top: 1px solid var(--color-border-divider);
|
||
}
|
||
.chats-menu-new {
|
||
flex: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
/* Mobile: popover becomes a full-width drawer anchored under the header. */
|
||
@media (max-width: 639px) {
|
||
.chats-menu-popover {
|
||
position: fixed;
|
||
top: 56px;
|
||
left: 0;
|
||
right: 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
border-radius: 0;
|
||
border-left: none;
|
||
border-right: none;
|
||
max-height: calc(100dvh - 56px);
|
||
}
|
||
}
|
||
|
||
/* Settings drawer — Manage mode toggle row at the top */
|
||
.chat-settings-toggle-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-sm) 0;
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.chat-settings-toggle-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.chat-settings-toggle-title {
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.chat-settings-toggle-title i {
|
||
color: var(--color-accent);
|
||
font-size: 0.8rem;
|
||
}
|
||
.chat-settings-toggle-desc {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* Settings drawer — destructive action area at the bottom */
|
||
.chat-settings-danger-zone {
|
||
margin-top: var(--spacing-md);
|
||
padding-top: var(--spacing-md);
|
||
border-top: 1px solid var(--color-border-divider);
|
||
}
|
||
.chat-settings-danger-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
width: 100%;
|
||
justify-content: center;
|
||
padding: 8px var(--spacing-md);
|
||
background: transparent;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-error);
|
||
font-family: inherit;
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background var(--duration-fast), border-color var(--duration-fast);
|
||
}
|
||
.chat-settings-danger-btn:hover {
|
||
background: rgba(220, 38, 38, 0.08);
|
||
border-color: var(--color-error);
|
||
}
|
||
|
||
/* Activity group (thinking + tools collapsed into one line) */
|
||
@keyframes shimmer {
|
||
0% { background-position: -200% 0; }
|
||
100% { background-position: 200% 0; }
|
||
}
|
||
|
||
.chat-activity-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
border-left: 2px solid var(--color-border-subtle);
|
||
}
|
||
.chat-activity-streaming {
|
||
border-left-color: var(--color-primary);
|
||
}
|
||
.chat-activity-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
padding: 6px 12px;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
color: var(--color-text-muted);
|
||
transition: color 150ms;
|
||
width: 100%;
|
||
text-align: left;
|
||
}
|
||
.chat-activity-toggle:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.chat-activity-toggle i {
|
||
font-size: 0.5rem;
|
||
flex-shrink: 0;
|
||
opacity: 0.4;
|
||
}
|
||
.chat-activity-summary {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.chat-activity-count {
|
||
display: inline-block;
|
||
margin-left: 6px;
|
||
padding: 0 5px;
|
||
border-radius: 999px;
|
||
background: var(--color-bg-tertiary);
|
||
font-size: 0.6rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.chat-activity-shimmer {
|
||
background: linear-gradient(
|
||
90deg,
|
||
var(--color-text-muted) 0%,
|
||
var(--color-text-muted) 40%,
|
||
var(--color-primary) 50%,
|
||
var(--color-text-muted) 60%,
|
||
var(--color-text-muted) 100%
|
||
);
|
||
background-size: 200% 100%;
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
animation: shimmer 3s ease-in-out infinite;
|
||
}
|
||
.chat-activity-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 2px 0 6px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
.chat-activity-item {
|
||
padding: 3px 12px;
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-muted);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
border-left: 2px solid transparent;
|
||
margin-left: -2px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
.chat-activity-item-label {
|
||
font-size: 0.575rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-muted);
|
||
opacity: 0.6;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.chat-activity-item-text {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
}
|
||
.chat-activity-item-content {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
overflow-wrap: anywhere;
|
||
min-width: 0;
|
||
}
|
||
.chat-activity-item-content.chat-activity-live {
|
||
max-height: 300px;
|
||
}
|
||
.chat-activity-item-content p { margin: 0 0 4px; }
|
||
.chat-activity-item-content p:last-child { margin-bottom: 0; }
|
||
.chat-activity-item-content pre {
|
||
background: var(--color-bg-tertiary);
|
||
padding: var(--spacing-xs);
|
||
border-radius: var(--radius-sm);
|
||
overflow-x: auto;
|
||
font-size: 0.75rem;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.chat-activity-item-content code {
|
||
word-break: break-word;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.chat-activity-item-code {
|
||
margin: 2px 0 0;
|
||
font-size: 0.65rem;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
color: var(--color-text-muted);
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
}
|
||
.chat-activity-item-code code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.65rem;
|
||
}
|
||
.chat-activity-params {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
margin-top: 2px;
|
||
}
|
||
.chat-activity-param {
|
||
display: flex;
|
||
gap: 6px;
|
||
font-size: 0.675rem;
|
||
line-height: 1.4;
|
||
word-break: break-word;
|
||
}
|
||
.chat-activity-param-key {
|
||
color: var(--color-text-muted);
|
||
flex-shrink: 0;
|
||
opacity: 0.7;
|
||
}
|
||
.chat-activity-param-val {
|
||
color: var(--color-text-secondary);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
min-width: 0;
|
||
}
|
||
.chat-activity-param-val-long {
|
||
max-height: 80px;
|
||
overflow-y: auto;
|
||
}
|
||
.chat-activity-thinking {
|
||
border-left-color: var(--color-info-border);
|
||
}
|
||
.chat-activity-tool-call {
|
||
border-left-color: var(--color-warning-border);
|
||
}
|
||
.chat-activity-tool-result {
|
||
border-left-color: var(--color-success-border);
|
||
}
|
||
|
||
/* Context window progress bar */
|
||
.chat-context-bar {
|
||
position: relative;
|
||
height: 18px;
|
||
background: var(--color-bg-tertiary);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
overflow: hidden;
|
||
}
|
||
.chat-context-progress {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100%;
|
||
transition: width 300ms ease;
|
||
opacity: 0.3;
|
||
}
|
||
.chat-context-label {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
font-size: 0.625rem;
|
||
color: var(--color-text-muted);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Chat header */
|
||
.chat-header {
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
background: var(--color-bg-secondary);
|
||
flex-shrink: 0;
|
||
min-height: 40px;
|
||
}
|
||
.chat-header-title {
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 0 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.chat-header-shield {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.75rem;
|
||
color: var(--color-accent);
|
||
background: var(--color-accent-light);
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-header-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-header-actions .btn-secondary.active {
|
||
background: var(--color-primary-light);
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
/* Focus mode: once a conversation is underway, fade non-essential
|
||
header chrome and pull the messages padding tighter. Hover or focus
|
||
on the header brings everything back; Esc removes focus mode for the
|
||
rest of the session. */
|
||
.chat--focus .chat-header-title,
|
||
.chat--focus .chat-header-shield {
|
||
opacity: 0.45;
|
||
transition: opacity var(--duration-normal) var(--ease-default);
|
||
}
|
||
.chat--focus .chat-header:hover .chat-header-title,
|
||
.chat--focus .chat-header:hover .chat-header-shield,
|
||
.chat--focus .chat-header:focus-within .chat-header-title,
|
||
.chat--focus .chat-header:focus-within .chat-header-shield {
|
||
opacity: 1;
|
||
}
|
||
.chat--focus .chat-messages {
|
||
padding-top: var(--spacing-sm);
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.chat--focus .chat-header-title,
|
||
.chat--focus .chat-header-shield,
|
||
.chat-messages { transition: none; }
|
||
}
|
||
/* Chat MCP dropdown */
|
||
.chat-mcp-dropdown {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.chat-mcp-dropdown .btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.chat-mcp-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 9px;
|
||
background: rgba(255,255,255,0.25);
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
}
|
||
.chat-mcp-dropdown-menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
left: 0;
|
||
z-index: 100;
|
||
min-width: 240px;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
background: var(--color-bg-primary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg);
|
||
animation: dropdownIn 120ms ease-out;
|
||
}
|
||
.chat-mcp-dropdown-loading,
|
||
.chat-mcp-dropdown-empty {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.chat-mcp-dropdown-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
.chat-mcp-select-all {
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
font-size: 0.75rem;
|
||
color: var(--color-accent);
|
||
cursor: pointer;
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
}
|
||
.chat-mcp-select-all:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.chat-mcp-server-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
cursor: pointer;
|
||
transition: background 120ms;
|
||
}
|
||
.chat-mcp-server-item:hover {
|
||
background: var(--color-bg-hover);
|
||
}
|
||
.chat-mcp-server-item input[type="checkbox"] {
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-mcp-server-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
min-width: 0;
|
||
}
|
||
.chat-mcp-server-name {
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.chat-mcp-server-tools {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
|
||
/* Client MCP status indicators */
|
||
.chat-client-mcp-status {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
background: var(--color-text-tertiary);
|
||
}
|
||
.chat-client-mcp-status-connected {
|
||
background: var(--color-success);
|
||
}
|
||
.chat-client-mcp-status-connecting {
|
||
background: var(--color-warning);
|
||
animation: pulse 1s infinite;
|
||
}
|
||
.chat-client-mcp-status-error {
|
||
background: var(--color-error);
|
||
}
|
||
.chat-client-mcp-status-disconnected {
|
||
background: var(--color-text-tertiary);
|
||
}
|
||
|
||
/* Chat model info panel */
|
||
.chat-model-info-panel {
|
||
background: var(--color-bg-secondary);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
animation: fadeIn 150ms ease;
|
||
}
|
||
.chat-model-info-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
}
|
||
.chat-model-info-body {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
.chat-model-info-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 0.8125rem;
|
||
padding: 2px 0;
|
||
}
|
||
.chat-model-info-row > span:first-child {
|
||
color: var(--color-text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
.chat-model-info-row > span:last-child {
|
||
color: var(--color-text-primary);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.75rem;
|
||
max-width: 60%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
text-align: right;
|
||
}
|
||
|
||
/* Settings drawer */
|
||
.chat-settings-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
z-index: 10;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 200ms;
|
||
}
|
||
.chat-settings-overlay.open {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
.chat-settings-drawer {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 320px;
|
||
max-width: 90%;
|
||
background: var(--color-bg-secondary);
|
||
border-left: 1px solid var(--color-border-subtle);
|
||
z-index: 11;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform: translateX(100%);
|
||
transition: transform 250ms var(--ease-default);
|
||
box-shadow: var(--shadow-lg);
|
||
will-change: transform;
|
||
}
|
||
.chat-settings-drawer.open {
|
||
transform: translateX(0);
|
||
}
|
||
.chat-settings-drawer-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
font-weight: 600;
|
||
font-size: 0.875rem;
|
||
}
|
||
.chat-settings-drawer-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--spacing-md);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
/* Chat search */
|
||
.chat-search-wrapper {
|
||
position: relative;
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
.chat-search-icon {
|
||
position: absolute;
|
||
left: 8px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.7rem;
|
||
pointer-events: none;
|
||
}
|
||
.chat-search-input {
|
||
width: 100%;
|
||
padding: 5px 24px 5px 26px;
|
||
background: var(--color-bg-primary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-primary);
|
||
outline: none;
|
||
transition: border-color 150ms;
|
||
}
|
||
.chat-search-input:focus {
|
||
border-color: var(--color-primary-border);
|
||
}
|
||
.chat-search-input::placeholder {
|
||
color: var(--color-text-muted);
|
||
}
|
||
.chat-search-clear {
|
||
position: absolute;
|
||
right: 6px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
font-size: 0.7rem;
|
||
padding: 2px;
|
||
}
|
||
|
||
/* Chat list item actions */
|
||
.chat-list-item-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
opacity: 0;
|
||
transition: opacity 150ms;
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-list-item:hover .chat-list-item-actions {
|
||
opacity: 1;
|
||
}
|
||
.chat-list-item-actions button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 2px;
|
||
font-size: 0.7rem;
|
||
}
|
||
.chat-list-item-actions button:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
.chat-list-item-actions .chat-list-item-delete:hover {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
/* Max tokens/sec badge */
|
||
.chat-max-tps-badge {
|
||
background: rgba(59, 130, 246, 0.15);
|
||
color: var(--color-primary);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-full);
|
||
font-weight: 600;
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
/* Slider styles */
|
||
.chat-slider {
|
||
width: 100%;
|
||
height: 4px;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
background: var(--color-border-default);
|
||
border-radius: 2px;
|
||
outline: none;
|
||
}
|
||
.chat-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--color-primary);
|
||
cursor: pointer;
|
||
}
|
||
.chat-slider::-moz-range-thumb {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--color-primary);
|
||
cursor: pointer;
|
||
border: none;
|
||
}
|
||
.chat-slider-labels {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.625rem;
|
||
color: var(--color-text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* Message inline files */
|
||
.chat-message-files {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
flex-wrap: wrap;
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
.chat-file-inline {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 6px;
|
||
background: color-mix(in oklab, currentColor 12%, transparent);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.chat-inline-image {
|
||
max-width: 200px;
|
||
max-height: 200px;
|
||
border-radius: var(--radius-md);
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
|
||
.chat-files {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
flex-wrap: wrap;
|
||
padding: var(--spacing-xs) var(--spacing-lg);
|
||
}
|
||
|
||
.chat-file-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 8px;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-full);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
max-width: 220px;
|
||
}
|
||
/* Image attachments preview as a thumbnail instead of a bare icon. */
|
||
.chat-file-badge--image {
|
||
padding: 3px 8px 3px 3px;
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
.chat-file-thumb {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: var(--radius-sm);
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
}
|
||
.chat-file-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chat-file-badge button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
font-size: 0.625rem;
|
||
}
|
||
|
||
/* Studio tabs */
|
||
.studio-tabs {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
padding: var(--spacing-sm) var(--spacing-xl) 0;
|
||
background: var(--color-bg-primary);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.studio-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
background: none;
|
||
border: none;
|
||
padding: 10px var(--spacing-md);
|
||
font-size: var(--text-sm);
|
||
font-family: inherit;
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
transition: color var(--duration-fast), border-color var(--duration-fast);
|
||
}
|
||
|
||
.studio-tab:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.studio-tab-active {
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
|
||
/* Two-column layout for media generation pages */
|
||
.media-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(320px, 420px) 1fr;
|
||
gap: var(--spacing-lg);
|
||
padding: var(--spacing-xl);
|
||
max-width: var(--page-max-default);
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
align-items: start;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.media-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.media-controls {
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-lg);
|
||
box-shadow: var(--shadow-subtle);
|
||
position: sticky;
|
||
top: var(--spacing-lg);
|
||
}
|
||
.media-controls .form-group {
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.media-controls .form-grid-2col,
|
||
.media-controls .form-grid-3col {
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.media-controls .form-grid-2col .form-group,
|
||
.media-controls .form-grid-3col .form-group {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.media-controls .page-header {
|
||
margin-bottom: var(--spacing-lg);
|
||
padding-bottom: var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
}
|
||
|
||
.media-controls .page-title {
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-semibold);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.media-controls .page-title i {
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.media-preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.media-result {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-lg);
|
||
min-height: 320px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.media-result img,
|
||
.media-result video {
|
||
max-width: 100%;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
|
||
.media-result-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: var(--spacing-sm);
|
||
width: 100%;
|
||
}
|
||
|
||
/* Clickable result thumbnail (opens the lightbox). */
|
||
.media-result-thumb {
|
||
position: relative;
|
||
display: block;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
cursor: zoom-in;
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
}
|
||
.media-result-thumb img {
|
||
display: block;
|
||
width: 100%;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.media-result-thumb__zoom {
|
||
position: absolute;
|
||
top: var(--spacing-xs);
|
||
right: var(--spacing-xs);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg-overlay);
|
||
color: var(--color-text-primary);
|
||
font-size: var(--text-xs);
|
||
opacity: 0;
|
||
transition: opacity var(--duration-fast) var(--ease-default);
|
||
}
|
||
.media-result-thumb:hover .media-result-thumb__zoom,
|
||
.media-result-thumb:focus-visible .media-result-thumb__zoom { opacity: 1; }
|
||
|
||
/* Fullscreen image lightbox. */
|
||
.lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 200;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: var(--spacing-xl);
|
||
background: var(--color-modal-backdrop);
|
||
animation: fadeIn var(--duration-fast) var(--ease-default);
|
||
}
|
||
.lightbox__img {
|
||
max-width: 92vw;
|
||
max-height: 88vh;
|
||
object-fit: contain;
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
.lightbox__toolbar {
|
||
position: absolute;
|
||
top: var(--spacing-md);
|
||
right: var(--spacing-md);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.lightbox__count { color: var(--color-text-secondary); font-size: var(--text-sm); margin-right: var(--spacing-xs); }
|
||
.lightbox__nav {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 44px;
|
||
height: 44px;
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text-primary);
|
||
cursor: pointer;
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
.lightbox__nav:hover { border-color: var(--color-primary-border); color: var(--color-primary); }
|
||
.lightbox__nav--prev { left: var(--spacing-lg); }
|
||
.lightbox__nav--next { right: var(--spacing-lg); }
|
||
|
||
/* Generation progress: shimmer placeholder tiles + elapsed time. */
|
||
.gen-progress {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--spacing-lg);
|
||
width: 100%;
|
||
}
|
||
.gen-progress__tiles {
|
||
display: grid;
|
||
gap: var(--spacing-sm);
|
||
width: 100%;
|
||
max-width: 520px;
|
||
}
|
||
.gen-progress__tiles--n1 { grid-template-columns: 1fr; }
|
||
.gen-progress__tiles--n2,
|
||
.gen-progress__tiles--n3,
|
||
.gen-progress__tiles--n4 { grid-template-columns: repeat(2, 1fr); }
|
||
.gen-progress__tile {
|
||
width: 100%;
|
||
aspect-ratio: 1 / 1;
|
||
height: auto;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.gen-progress__status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.gen-progress__time { color: var(--color-text-muted); }
|
||
|
||
/* Talk voice visualizer: status-tinted frequency bars. */
|
||
.voice-viz {
|
||
display: block;
|
||
width: 100%;
|
||
height: 88px;
|
||
margin-bottom: var(--spacing-md);
|
||
--viz-color: var(--color-text-muted);
|
||
transition: opacity var(--duration-normal) var(--ease-default);
|
||
}
|
||
.voice-viz--disconnected { --viz-color: var(--color-text-disabled); opacity: 0.55; }
|
||
.voice-viz--connected { --viz-color: var(--color-primary); }
|
||
.voice-viz--listening { --viz-color: var(--color-success); }
|
||
.voice-viz--speaking { --viz-color: var(--color-accent); }
|
||
.voice-viz--error { --viz-color: var(--color-error); }
|
||
|
||
/* Media generation history */
|
||
.media-history {
|
||
margin-top: var(--spacing-md);
|
||
}
|
||
|
||
.media-history-clear-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 2px 6px;
|
||
font-size: 0.75rem;
|
||
border-radius: var(--radius-sm);
|
||
transition: color var(--duration-fast);
|
||
}
|
||
|
||
.media-history-clear-btn:hover {
|
||
color: var(--color-danger);
|
||
}
|
||
|
||
.media-history-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
padding: var(--spacing-xs) 0;
|
||
}
|
||
|
||
.media-history-empty {
|
||
text-align: center;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.8125rem;
|
||
padding: var(--spacing-md);
|
||
}
|
||
|
||
.media-history-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-sm);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
transition: background var(--duration-fast), transform var(--duration-fast);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.media-history-item:hover {
|
||
background: var(--color-primary-light);
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
.media-history-item.active {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.media-history-item-thumb {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.media-history-item-thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.media-history-item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.media-history-item-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.media-history-item-prompt {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 500;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.media-history-item-time {
|
||
font-size: 0.625rem;
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.media-history-item-model {
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.media-history-item-delete {
|
||
opacity: 0;
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 2px;
|
||
font-size: 0.75rem;
|
||
transition: opacity var(--duration-fast);
|
||
}
|
||
|
||
.media-history-item:hover .media-history-item-delete {
|
||
opacity: 1;
|
||
}
|
||
|
||
.media-history-item-delete:hover {
|
||
color: var(--color-danger);
|
||
}
|
||
|
||
/* ============================================================
|
||
Responsive
|
||
------------------------------------------------------------
|
||
Three viewport tiers, expressed as cascading media queries:
|
||
|
||
* desktop (≥1024px) — full sidebar, content margin-left = sidebar-width
|
||
* tablet (640–1023) — 52px icon rail; tap hamburger to overlay-expand
|
||
* mobile (<640px) — sidebar slides off-screen behind a top-bar drawer
|
||
|
||
Touch-target minimums apply across both tablet and mobile via the
|
||
first (max-width: 1023px) block.
|
||
============================================================ */
|
||
|
||
/* Touch-friendly sizing + shared layout simplifications (tablet + mobile) */
|
||
@media (max-width: 1023px) {
|
||
.hamburger-btn {
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
padding: 10px 12px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.nav-item {
|
||
min-height: 44px;
|
||
}
|
||
|
||
.sidebar-close-btn {
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 10px;
|
||
}
|
||
|
||
.mobile-header {
|
||
min-height: 56px;
|
||
}
|
||
|
||
/* Layouts that need to collapse to single-column on any narrow viewport */
|
||
.chat-sidebar { display: none; }
|
||
.chat-settings-drawer {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
.media-layout { grid-template-columns: 1fr; }
|
||
.media-controls { position: static; }
|
||
.page { padding: var(--spacing-md); }
|
||
|
||
/* The desktop collapse chevron is desktop-only — tablets auto-rail,
|
||
mobile uses the hamburger. */
|
||
.sidebar-collapse-btn { display: none; }
|
||
}
|
||
|
||
/* Tablet (640–1023): persistent icon rail; tap hamburger to overlay-expand */
|
||
@media (max-width: 1023px) and (min-width: 640px) {
|
||
.main-content,
|
||
.sidebar-is-collapsed .main-content {
|
||
margin-left: var(--sidebar-width-collapsed);
|
||
}
|
||
|
||
.mobile-header { display: none; }
|
||
|
||
.sidebar {
|
||
width: var(--sidebar-width-collapsed);
|
||
transform: translateX(0);
|
||
}
|
||
.sidebar.collapsed { width: var(--sidebar-width-collapsed); }
|
||
|
||
/* Apply collapsed visuals while not pinned-open. These mirror the
|
||
existing .sidebar.collapsed desktop rules so we re-use one look. */
|
||
.sidebar:not(.open) .nav-label,
|
||
.sidebar:not(.open) .nav-external,
|
||
.sidebar:not(.open) .sidebar-section-title,
|
||
.sidebar:not(.open) .sidebar-section-chevron { display: none; }
|
||
.sidebar:not(.open) .sidebar-logo-link { display: none; }
|
||
.sidebar:not(.open) .sidebar-logo-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
}
|
||
.sidebar:not(.open) .sidebar-header { justify-content: center; }
|
||
.sidebar:not(.open) .nav-item {
|
||
justify-content: center;
|
||
padding: 8px 0;
|
||
border-left-width: 2px;
|
||
}
|
||
.sidebar:not(.open) .nav-icon { width: auto; font-size: 1rem; }
|
||
.sidebar:not(.open) .sidebar-footer {
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.sidebar:not(.open) .sidebar-user-name,
|
||
.sidebar:not(.open) .sidebar-logout-btn { display: none; }
|
||
/* Center the avatar in the icon rail (mirrors .sidebar.collapsed, which
|
||
isn't necessarily present in the tablet :not(.open) state). */
|
||
.sidebar:not(.open) .sidebar-user { justify-content: center; }
|
||
.sidebar:not(.open) .sidebar-user-link { flex: 0; margin: 0; padding: 2px; }
|
||
|
||
/* Pinned open: overlay the full sidebar on top of content */
|
||
.sidebar.open {
|
||
width: var(--sidebar-width);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.sidebar-close-btn { display: none; }
|
||
.sidebar.open .sidebar-close-btn { display: flex; }
|
||
|
||
.sidebar-overlay {
|
||
display: block;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 40;
|
||
}
|
||
}
|
||
|
||
/* Mobile (<640): sidebar slides off-screen as a drawer */
|
||
@media (max-width: 639px) {
|
||
.main-content,
|
||
.sidebar-is-collapsed .main-content {
|
||
margin-left: 0;
|
||
}
|
||
|
||
.mobile-header { display: flex; }
|
||
|
||
.sidebar {
|
||
transform: translateX(-100%);
|
||
width: var(--sidebar-width);
|
||
}
|
||
.sidebar.collapsed { width: var(--sidebar-width); }
|
||
.sidebar.open { transform: translateX(0); }
|
||
|
||
.sidebar-close-btn { display: flex; }
|
||
|
||
/* When opened on mobile, even if the .collapsed class is present
|
||
from desktop preference, force the expanded look — drawer always
|
||
shows full labels. */
|
||
.sidebar.collapsed .nav-label,
|
||
.sidebar.collapsed .nav-external,
|
||
.sidebar.collapsed .sidebar-section-title { display: unset; }
|
||
.sidebar.collapsed .sidebar-logo-link { display: block; }
|
||
.sidebar.collapsed .sidebar-logo-icon { display: none; }
|
||
.sidebar.collapsed .nav-item {
|
||
justify-content: flex-start;
|
||
padding: 10px var(--spacing-md);
|
||
border-left-width: 3px;
|
||
}
|
||
.sidebar.collapsed .nav-icon {
|
||
width: 18px;
|
||
font-size: 0.85rem;
|
||
}
|
||
.sidebar.collapsed .sidebar-header { justify-content: space-between; }
|
||
|
||
.sidebar-overlay {
|
||
display: block;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 40;
|
||
}
|
||
}
|
||
|
||
/* Mobile reflow polish — phone-only (<640) layout adjustments for
|
||
page chrome that was designed flex-row on desktop. */
|
||
@media (max-width: 639px) {
|
||
/* Page header: stack title block + inline-action cluster vertically */
|
||
.page-header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
/* Filter chip rows scroll horizontally instead of wrapping into walls */
|
||
.filter-bar {
|
||
flex-wrap: nowrap;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
.filter-bar::-webkit-scrollbar { display: none; }
|
||
.filter-btn { flex-shrink: 0; }
|
||
|
||
.search-bar { min-width: 0; }
|
||
|
||
/* Tables go edge-to-edge; offsetting against .page padding gives full
|
||
bleed without changing the table layout itself. */
|
||
.table-container {
|
||
border-radius: 0;
|
||
border-left: 0;
|
||
border-right: 0;
|
||
margin-inline: calc(-1 * var(--spacing-md));
|
||
}
|
||
|
||
/* Operations toasts: scroll horizontally instead of wrapping */
|
||
.operations-bar {
|
||
overflow-x: auto;
|
||
flex-wrap: nowrap;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.operation-item { flex-shrink: 0; }
|
||
.operation-text {
|
||
max-width: 60vw;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
/* Reduced motion — disable non-essential transitions for users who
|
||
request it. Keeps focus/state changes accessible without animation. */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.sidebar,
|
||
.page-transition,
|
||
.operations-bar,
|
||
.page,
|
||
.main-content {
|
||
transition: none !important;
|
||
animation: none !important;
|
||
}
|
||
}
|
||
|
||
/* Canvas panel */
|
||
.canvas-panel {
|
||
position: relative;
|
||
width: 45%;
|
||
max-width: 720px;
|
||
flex-shrink: 0;
|
||
border-left: 1px solid var(--color-border-subtle);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--color-bg-primary);
|
||
overflow: hidden;
|
||
animation: canvasSlideIn var(--duration-reveal) var(--ease-reveal) both;
|
||
}
|
||
@keyframes canvasSlideIn {
|
||
from { opacity: 0; transform: translateX(24px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
/* Drag-to-resize handle on the panel's left edge. */
|
||
.canvas-resize-handle {
|
||
position: absolute;
|
||
top: 0;
|
||
left: -3px;
|
||
width: 7px;
|
||
height: 100%;
|
||
cursor: col-resize;
|
||
z-index: 2;
|
||
background: transparent;
|
||
transition: background var(--duration-fast) var(--ease-default);
|
||
}
|
||
.canvas-resize-handle:hover,
|
||
.canvas-resize-handle:active {
|
||
background: var(--color-primary-light);
|
||
}
|
||
/* On narrow screens the canvas takes the full width; no edge-drag resize. */
|
||
@media (max-width: 1024px) {
|
||
.canvas-resize-handle { display: none; }
|
||
.canvas-panel { width: 100% !important; max-width: none !important; }
|
||
}
|
||
.canvas-header-actions { display: inline-flex; gap: var(--spacing-xs); }
|
||
/* Fullscreen: the canvas covers the viewport. */
|
||
.canvas-panel--fullscreen {
|
||
position: fixed;
|
||
inset: 0;
|
||
width: 100% !important;
|
||
max-width: none !important;
|
||
z-index: 60;
|
||
border-left: none;
|
||
}
|
||
.canvas-panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
gap: var(--spacing-sm);
|
||
flex-shrink: 0;
|
||
}
|
||
.canvas-panel-title {
|
||
font-weight: 600;
|
||
font-size: 0.875rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.canvas-panel-tabs {
|
||
overflow-x: auto;
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
flex-shrink: 0;
|
||
scrollbar-width: thin;
|
||
}
|
||
.canvas-panel-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--color-border-subtle);
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.75rem;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
transition: all 150ms;
|
||
}
|
||
.canvas-panel-tab:hover { border-color: var(--color-border-default); }
|
||
.canvas-panel-tab.active {
|
||
background: var(--color-primary-light);
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
}
|
||
.canvas-panel-tab span {
|
||
max-width: 100px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.canvas-panel-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-xs) var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
flex-shrink: 0;
|
||
}
|
||
.canvas-toggle-group {
|
||
display: flex;
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
}
|
||
.canvas-toggle-btn {
|
||
padding: 2px 10px;
|
||
font-size: 0.75rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
transition: all 150ms;
|
||
}
|
||
.canvas-toggle-btn.active {
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
}
|
||
.canvas-panel-body {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: var(--spacing-md);
|
||
min-height: 0;
|
||
}
|
||
.canvas-panel-body pre {
|
||
margin: 0;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
/* Artifact card (inline in messages) */
|
||
.artifact-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
background: var(--color-bg-tertiary);
|
||
margin: var(--spacing-sm) 0;
|
||
transition: border-color 150ms;
|
||
}
|
||
.artifact-card:hover {
|
||
border-color: var(--color-primary);
|
||
}
|
||
.artifact-card-icon {
|
||
font-size: 1.1rem;
|
||
color: var(--color-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
.artifact-card-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.artifact-card-title {
|
||
font-weight: 600;
|
||
font-size: 0.8125rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.artifact-card-lang {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.artifact-card-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
.artifact-card-actions button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 4px 6px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.75rem;
|
||
transition: all 150ms;
|
||
}
|
||
.artifact-card-actions button:hover {
|
||
color: var(--color-primary);
|
||
background: var(--color-primary-light);
|
||
}
|
||
|
||
/* Resource cards (below agent messages) */
|
||
.resource-cards {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-xs);
|
||
margin-top: var(--spacing-xs);
|
||
}
|
||
.resource-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
background: var(--color-bg-secondary);
|
||
transition: border-color 150ms;
|
||
}
|
||
.resource-card:hover {
|
||
border-color: var(--color-primary);
|
||
}
|
||
.resource-card-thumb {
|
||
width: 40px;
|
||
height: 40px;
|
||
object-fit: cover;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.resource-card-label {
|
||
max-width: 120px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.resource-cards-more {
|
||
background: none;
|
||
border: 1px dashed var(--color-border-default);
|
||
border-radius: var(--radius-sm);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
}
|
||
.resource-cards-more:hover {
|
||
color: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
/* Canvas preview types */
|
||
.canvas-preview-iframe {
|
||
width: 100%;
|
||
min-height: 600px;
|
||
height: calc(100vh - 200px);
|
||
border: none;
|
||
background: white;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.canvas-preview-image {
|
||
max-width: 100%;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.canvas-preview-svg {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: var(--spacing-md);
|
||
}
|
||
.canvas-preview-svg svg {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
.canvas-preview-markdown {
|
||
padding: var(--spacing-sm);
|
||
}
|
||
.canvas-audio-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-lg);
|
||
}
|
||
.canvas-audio-icon {
|
||
font-size: 2rem;
|
||
color: var(--color-primary);
|
||
}
|
||
.canvas-url-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md);
|
||
}
|
||
.canvas-url-card a {
|
||
color: var(--color-primary);
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* Canvas mode toggle */
|
||
.canvas-mode-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.canvas-mode-toggle .canvas-mode-label {
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
.canvas-mode-toggle .toggle {
|
||
transform: scale(0.8);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.canvas-panel {
|
||
position: fixed;
|
||
inset: 0;
|
||
width: 100%;
|
||
z-index: 50;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.card-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.filter-bar {
|
||
overflow-x: auto;
|
||
flex-wrap: nowrap;
|
||
padding-bottom: var(--spacing-xs);
|
||
}
|
||
|
||
.chat-header {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chat-header-title {
|
||
max-width: 120px;
|
||
}
|
||
|
||
.chat-empty-hints {
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.chat-empty-suggestions {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
|
||
/* MCP App Frame */
|
||
.mcp-app-frame-container {
|
||
width: 100%;
|
||
margin: var(--spacing-sm) 0;
|
||
border-radius: var(--border-radius-md);
|
||
overflow: hidden;
|
||
border: 1px solid var(--color-border-subtle);
|
||
}
|
||
|
||
.mcp-app-iframe {
|
||
width: 100%;
|
||
border: none;
|
||
display: block;
|
||
min-height: 100px;
|
||
max-height: 600px;
|
||
transition: height 0.2s ease;
|
||
background: var(--color-bg-primary);
|
||
}
|
||
|
||
.mcp-app-error {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
color: var(--color-text-danger, #e53e3e);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.mcp-app-reconnect-overlay {
|
||
padding: var(--spacing-sm);
|
||
text-align: center;
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
background: var(--color-bg-secondary);
|
||
border-top: 1px solid var(--color-border-subtle);
|
||
}
|
||
|
||
/* Confirm Dialog */
|
||
.confirm-dialog-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1050;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-modal-backdrop);
|
||
backdrop-filter: blur(4px);
|
||
animation: fadeIn var(--duration-normal) var(--ease-spring);
|
||
}
|
||
.confirm-dialog {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: var(--radius-lg);
|
||
max-width: 420px;
|
||
width: 90%;
|
||
padding: var(--spacing-lg);
|
||
box-shadow: var(--shadow-md);
|
||
animation: popIn var(--duration-slow) var(--ease-spring);
|
||
will-change: transform, opacity;
|
||
}
|
||
@keyframes slideUp {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.confirm-dialog-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.confirm-dialog-danger-icon {
|
||
color: var(--color-error);
|
||
font-size: 1.125rem;
|
||
}
|
||
.confirm-dialog-title {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.confirm-dialog-body {
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: var(--spacing-lg);
|
||
line-height: 1.5;
|
||
}
|
||
.confirm-dialog-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.confirm-dialog-actions .btn-danger {
|
||
background: var(--color-error);
|
||
color: var(--color-text-inverse);
|
||
border: 1px solid var(--color-error);
|
||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), var(--shadow-inset-hi);
|
||
}
|
||
.confirm-dialog-actions .btn-danger:hover:not(:disabled) {
|
||
filter: brightness(1.06);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
/* Home page */
|
||
.home-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
justify-content: center;
|
||
gap: var(--space-section);
|
||
max-width: var(--page-max-medium);
|
||
margin: 0 auto;
|
||
padding: var(--space-section) var(--spacing-lg);
|
||
width: 100%;
|
||
}
|
||
.home-header {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-lg);
|
||
flex-wrap: wrap;
|
||
}
|
||
.home-eyebrow {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.14em;
|
||
color: var(--color-eyebrow);
|
||
}
|
||
.home-greeting {
|
||
font-family: var(--font-sans);
|
||
font-size: clamp(2rem, 1.4rem + 3vw, var(--text-4xl));
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: -0.02em;
|
||
line-height: 1.05;
|
||
margin: var(--spacing-xs) 0 0;
|
||
}
|
||
.home-status-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
flex-wrap: wrap;
|
||
}
|
||
.home-hero {
|
||
text-align: center;
|
||
padding: var(--spacing-sm) 0 var(--spacing-md);
|
||
}
|
||
.home-logo {
|
||
width: 72px;
|
||
height: auto;
|
||
margin: 0 auto;
|
||
display: block;
|
||
}
|
||
|
||
/* Home resource bar - prominent */
|
||
.home-resource-bar {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-subtle);
|
||
}
|
||
.home-resource-bar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.home-resource-label {
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.home-resource-pct {
|
||
margin-left: auto;
|
||
font-family: var(--font-mono);
|
||
font-weight: var(--font-weight-medium);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.home-resource-track {
|
||
width: 100%;
|
||
height: 6px;
|
||
background: var(--color-surface-sunken);
|
||
border-radius: var(--radius-full);
|
||
overflow: hidden;
|
||
}
|
||
.home-resource-fill {
|
||
height: 100%;
|
||
border-radius: var(--radius-full);
|
||
transition: width 500ms ease;
|
||
}
|
||
.home-cluster-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
.home-cluster-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-success);
|
||
display: inline-block;
|
||
}
|
||
|
||
/* Home assistant CTA — a self-explanatory entry point for the in-process
|
||
admin tool surface. Distinct from the chat composer below it; uses the
|
||
accent token + a subtle gradient so it reads as a primary action without
|
||
looking AI-slop generative. */
|
||
.home-assistant-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
width: 100%;
|
||
margin-bottom: var(--spacing-md);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-accent);
|
||
border-radius: var(--radius-xl);
|
||
cursor: pointer;
|
||
text-align: left;
|
||
font: inherit;
|
||
color: var(--color-text);
|
||
transition: background-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
|
||
}
|
||
.home-assistant-card:hover {
|
||
background: var(--color-accent-light, var(--color-surface-hover));
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
.home-assistant-card:active {
|
||
transform: translateY(0);
|
||
}
|
||
.home-assistant-icon {
|
||
flex: 0 0 auto;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: var(--color-accent);
|
||
color: var(--color-on-accent, #ffffff);
|
||
font-size: 1.1rem;
|
||
}
|
||
.home-assistant-text {
|
||
flex: 1 1 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.home-assistant-title {
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
}
|
||
.home-assistant-desc {
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.home-assistant-cta {
|
||
flex: 0 0 auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
color: var(--color-accent);
|
||
white-space: nowrap;
|
||
}
|
||
@media (max-width: 600px) {
|
||
.home-assistant-card {
|
||
flex-wrap: wrap;
|
||
}
|
||
.home-assistant-cta {
|
||
flex-basis: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
|
||
/* Home chat card */
|
||
.home-chat-card {
|
||
width: 100%;
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-xl);
|
||
padding: var(--spacing-lg);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
.home-model-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.home-file-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-xs);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.home-file-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.home-file-tag button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
font-size: 0.625rem;
|
||
}
|
||
.home-file-tag button:hover {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
/* Home input container */
|
||
.home-input-container {
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-lg);
|
||
transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
|
||
}
|
||
.home-input-container:focus-within {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||
}
|
||
.home-textarea {
|
||
width: 100%;
|
||
background: transparent;
|
||
color: var(--color-text-primary);
|
||
border: none;
|
||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||
padding: var(--spacing-md);
|
||
font-size: var(--text-base);
|
||
font-family: inherit;
|
||
outline: none;
|
||
resize: none;
|
||
min-height: 84px;
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.home-textarea::placeholder {
|
||
color: var(--color-text-muted);
|
||
}
|
||
.home-input-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-xs) var(--spacing-sm) var(--spacing-xs) var(--spacing-md);
|
||
border-top: 1px solid var(--color-border-divider);
|
||
}
|
||
.home-attach-buttons {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
.home-attach-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 6px 8px;
|
||
font-size: var(--text-sm);
|
||
border-radius: var(--radius-md);
|
||
transition: color var(--duration-fast), background var(--duration-fast);
|
||
}
|
||
.home-attach-btn:hover {
|
||
color: var(--color-primary);
|
||
background: var(--color-primary-light);
|
||
}
|
||
.home-input-hint {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.home-send-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
background: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
border: none;
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-sm);
|
||
cursor: pointer;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: background var(--duration-fast), transform 100ms, box-shadow var(--duration-fast);
|
||
flex-shrink: 0;
|
||
}
|
||
.home-send-btn:hover:not(:disabled) {
|
||
background: var(--color-primary-hover);
|
||
transform: scale(1.05);
|
||
}
|
||
.home-send-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
.home-send-btn:active:not(:disabled) {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
/* Home quick links */
|
||
.home-quick-links {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-sm);
|
||
justify-content: center;
|
||
}
|
||
.home-link-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
padding: 8px var(--spacing-md);
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
box-shadow: var(--shadow-subtle);
|
||
transition: all var(--duration-fast);
|
||
}
|
||
.home-link-btn:hover {
|
||
border-color: var(--color-primary-border);
|
||
color: var(--color-primary);
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
/* Quiet variant: a deliberately understated link (e.g. Documentation) so the
|
||
quick-links row keeps a single clear primary action. */
|
||
.home-link-btn--quiet { opacity: 0.8; }
|
||
|
||
/* Home loaded models */
|
||
.home-loaded {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.home-loaded-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.home-loaded-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 3px 6px 3px 10px;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-divider);
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.home-loaded-item button {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-error);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
line-height: 1;
|
||
font-size: 0.625rem;
|
||
}
|
||
.home-loaded-empty {
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-sm);
|
||
margin: 0;
|
||
}
|
||
.home-stop-all {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
/* Home wizard (no models) */
|
||
.home-wizard {
|
||
max-width: 48rem;
|
||
width: 100%;
|
||
/* .home-page is a stretch column flex; a max-width child would otherwise
|
||
pin to the left cross-start edge. Center it. */
|
||
margin: 0 auto;
|
||
}
|
||
.home-wizard-hero {
|
||
text-align: center;
|
||
padding: var(--spacing-xl) 0;
|
||
}
|
||
.home-wizard-hero h1 {
|
||
font-size: var(--text-2xl);
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: -0.015em;
|
||
margin-bottom: var(--spacing-sm);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.home-wizard-hero p {
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.home-wizard-steps {
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
.home-wizard-steps h2 {
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-semibold);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.home-wizard-step {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
align-items: flex-start;
|
||
padding: var(--spacing-sm) 0;
|
||
}
|
||
.home-wizard-step-num {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: var(--color-primary);
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
flex-shrink: 0;
|
||
}
|
||
.home-wizard-step strong {
|
||
display: block;
|
||
margin-bottom: 2px;
|
||
}
|
||
.home-wizard-step p {
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
margin: 0;
|
||
}
|
||
.home-wizard-actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
justify-content: center;
|
||
}
|
||
|
||
/* ──────────────────── Home: hardware-aware starter models ──────────────────── */
|
||
|
||
.home-starters {
|
||
margin: var(--spacing-lg) 0;
|
||
padding: var(--spacing-lg);
|
||
}
|
||
.home-starters-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
}
|
||
.home-starters-head strong {
|
||
font-size: 0.9375rem;
|
||
}
|
||
.home-starters-tier {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.home-starters-sub {
|
||
margin: var(--spacing-xs) 0 var(--spacing-md);
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.home-starters-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.home-starters-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-xs) 0;
|
||
}
|
||
.home-starters-name {
|
||
font-weight: 500;
|
||
font-size: 0.875rem;
|
||
word-break: break-all;
|
||
}
|
||
.home-starters-badge {
|
||
font-size: 0.625rem;
|
||
}
|
||
.home-starters-size {
|
||
margin-left: auto;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ──────────────────── Models gallery: recommended-for-your-hardware strip ──────────────────── */
|
||
|
||
.rec-models {
|
||
margin-bottom: var(--spacing-md);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
}
|
||
/* Collapsed keeps the strip and its heading, only dropping the cards, so the
|
||
panel is recoverable in place rather than gone. Tighter block padding is
|
||
what actually returns vertical space to the gallery. */
|
||
.rec-models--collapsed {
|
||
padding-top: var(--spacing-sm);
|
||
padding-bottom: var(--spacing-sm);
|
||
}
|
||
.rec-models-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm) var(--spacing-md);
|
||
flex-wrap: wrap;
|
||
}
|
||
.rec-models-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
font: inherit;
|
||
color: var(--color-text-primary);
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
.rec-models-toggle i {
|
||
color: var(--color-primary);
|
||
}
|
||
.rec-models-chevron {
|
||
font-size: 0.75rem;
|
||
transition: transform 150ms ease;
|
||
}
|
||
.rec-models--collapsed .rec-models-chevron {
|
||
transform: rotate(-90deg);
|
||
}
|
||
.rec-models-note {
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
flex: 1 1 12rem;
|
||
min-width: 0;
|
||
}
|
||
.rec-models-dismiss {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
.rec-models-dismiss:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
.rec-models-grid {
|
||
display: grid;
|
||
/* min() keeps the 220px track from forcing horizontal overflow on narrow
|
||
phones, where one column is all that fits anyway. */
|
||
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
|
||
gap: var(--spacing-sm);
|
||
margin-top: var(--spacing-md);
|
||
/* Reveal animates opacity and transform only; the collapsed state is the
|
||
[hidden] attribute, so no height is being animated. */
|
||
animation: rec-models-reveal 180ms ease-out;
|
||
}
|
||
/* The class sets display:grid, which would otherwise beat the UA [hidden] rule. */
|
||
.rec-models-grid[hidden] {
|
||
display: none;
|
||
}
|
||
@keyframes rec-models-reveal {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.rec-models-grid {
|
||
animation: none;
|
||
}
|
||
.rec-models-chevron {
|
||
transition: none;
|
||
}
|
||
}
|
||
.rec-models-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-bg-primary);
|
||
}
|
||
.rec-models-item-name {
|
||
font-weight: 500;
|
||
font-size: 0.8125rem;
|
||
word-break: break-all;
|
||
}
|
||
.rec-models-item-meta {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.rec-models-item-fit {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* ──────────────────── Home: drop-in endpoint / API compatibility ──────────────────── */
|
||
|
||
.home-connect {
|
||
max-width: 760px;
|
||
margin: var(--spacing-xl) auto 0;
|
||
padding: var(--spacing-lg);
|
||
}
|
||
.home-connect-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-md);
|
||
}
|
||
.home-connect-icon {
|
||
flex-shrink: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: var(--radius-lg);
|
||
background: var(--color-primary-light);
|
||
color: var(--color-primary);
|
||
}
|
||
.home-connect-title {
|
||
margin: 0;
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.home-connect-sub {
|
||
margin: 2px 0 0;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
line-height: 1.5;
|
||
}
|
||
.home-connect-url {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin-top: var(--spacing-md);
|
||
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
.home-connect-url code {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-primary);
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
.home-connect-url .btn { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
|
||
.home-connect-dismiss {
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
background: none;
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
transition: background var(--duration-fast) var(--ease-default), color var(--duration-fast) var(--ease-default);
|
||
}
|
||
.home-connect-dismiss:hover { background: var(--color-surface-hover); color: var(--color-text-primary); }
|
||
.home-connect-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
margin-top: var(--spacing-md);
|
||
padding: var(--spacing-xs) 0;
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-primary);
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
cursor: pointer;
|
||
}
|
||
.home-connect-toggle:hover { color: var(--color-primary-hover); }
|
||
.home-connect-toggle i { font-size: 0.7em; }
|
||
.home-connect-endpoints { margin-top: var(--spacing-sm); }
|
||
.home-connect-block { margin-top: var(--spacing-md); }
|
||
.home-connect-block-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.home-connect-block-title {
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.home-connect-apis {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.home-connect-api {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.home-connect-api-name { font-weight: var(--font-weight-medium); color: var(--color-text-secondary); }
|
||
.home-connect-api-path { font-size: var(--text-xs); color: var(--color-text-muted); background: none; padding: 0; }
|
||
.home-connect-docs {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-primary);
|
||
text-decoration: none;
|
||
}
|
||
.home-connect-docs:hover { color: var(--color-primary-hover); }
|
||
.home-connect-docs i { font-size: 0.75rem; transition: transform var(--duration-fast) var(--ease-out, ease-out); }
|
||
.home-connect-docs:hover i { transform: translateX(3px); }
|
||
@media (max-width: 560px) {
|
||
.home-connect-apis { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* ──────────────────── Biometrics (face + voice recognition) ──────────────────── */
|
||
|
||
.biometrics-page {
|
||
padding: var(--spacing-xl);
|
||
max-width: var(--page-max-wide);
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
animation: fadeIn var(--duration-normal) var(--ease-default);
|
||
}
|
||
|
||
.biometrics-page__header {
|
||
display: grid;
|
||
grid-template-columns: 1fr minmax(240px, 320px);
|
||
gap: var(--spacing-lg);
|
||
align-items: end;
|
||
margin-bottom: var(--spacing-lg);
|
||
padding-bottom: var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
}
|
||
|
||
.biometrics-page__header .page-title i {
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.biometrics-page__model {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.biometrics-page__body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-lg);
|
||
min-width: 0;
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.biometrics-page__header {
|
||
grid-template-columns: 1fr;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
|
||
/* Tabs — flat, underlined, inherit page tone */
|
||
.biometrics-tabs {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
border-bottom: 1px solid var(--color-border-subtle);
|
||
overflow-x: auto;
|
||
scrollbar-width: none;
|
||
}
|
||
.biometrics-tabs::-webkit-scrollbar { display: none; }
|
||
|
||
.biometrics-tab {
|
||
background: transparent;
|
||
border: 0;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
color: var(--color-text-secondary);
|
||
font: inherit;
|
||
font-weight: var(--font-weight-medium);
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
border-bottom: 2px solid transparent;
|
||
min-height: 44px;
|
||
transition: color var(--duration-fast), border-color var(--duration-fast);
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-tab:hover { color: var(--color-text-primary); }
|
||
.biometrics-tab.active {
|
||
color: var(--color-text-primary);
|
||
border-bottom-color: var(--color-accent);
|
||
}
|
||
.biometrics-tab i { color: var(--color-accent); font-size: 0.9em; }
|
||
|
||
/* Two-column workflow layout */
|
||
.biometrics-twocol {
|
||
display: grid;
|
||
grid-template-columns: minmax(300px, 380px) 1fr;
|
||
gap: var(--spacing-lg);
|
||
align-items: start;
|
||
min-width: 0;
|
||
}
|
||
@media (max-width: 980px) {
|
||
.biometrics-twocol { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.biometrics-panel {
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-lg);
|
||
box-shadow: var(--shadow-subtle), var(--shadow-inset-top);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.biometrics-panel__title {
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-semibold);
|
||
margin: 0;
|
||
color: var(--color-text-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-panel__title i { color: var(--color-accent); }
|
||
.biometrics-panel__note {
|
||
margin: 0;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
|
||
.biometrics-results {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.biometrics-empty {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px dashed var(--color-border-default);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-2xl) var(--spacing-lg);
|
||
text-align: center;
|
||
min-height: 300px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.biometrics-empty > i {
|
||
font-size: 2.5rem;
|
||
color: var(--color-accent);
|
||
opacity: 0.6;
|
||
}
|
||
.biometrics-empty h3 {
|
||
margin: 0;
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.biometrics-empty p {
|
||
margin: 0;
|
||
max-width: 48ch;
|
||
line-height: var(--leading-normal);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
/* Media input — file / webcam / record switcher */
|
||
.biometrics-mediainput {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-mediainput__tabs {
|
||
display: inline-flex;
|
||
gap: 2px;
|
||
padding: 2px;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-md);
|
||
align-self: flex-start;
|
||
}
|
||
.biometrics-mediainput__tab {
|
||
background: transparent;
|
||
border: 0;
|
||
font: inherit;
|
||
color: var(--color-text-secondary);
|
||
padding: 6px 12px;
|
||
min-height: 32px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
transition: background var(--duration-fast), color var(--duration-fast);
|
||
}
|
||
.biometrics-mediainput__tab:hover:not(:disabled) { color: var(--color-text-primary); }
|
||
.biometrics-mediainput__tab.active {
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text-primary);
|
||
box-shadow: var(--shadow-subtle);
|
||
}
|
||
.biometrics-mediainput__tab:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
.biometrics-mediainput__body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.biometrics-mediainput__live {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-mediainput__video {
|
||
width: 100%;
|
||
aspect-ratio: 4 / 3;
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface-sunken);
|
||
object-fit: cover;
|
||
}
|
||
.biometrics-mediainput__controls {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-mediainput__controls .btn { flex: 1; min-height: 40px; }
|
||
|
||
.biometrics-mediainput__meter {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-bg-secondary);
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-sm);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.biometrics-mediainput__meter i { color: var(--color-text-muted); }
|
||
.biometrics-mediainput__meter.recording {
|
||
border-color: var(--color-error-border);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.biometrics-mediainput__meter.recording i {
|
||
color: var(--color-error);
|
||
animation: biometrics-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
@keyframes biometrics-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.35; }
|
||
}
|
||
|
||
.biometrics-mediainput__error {
|
||
margin: 0;
|
||
color: var(--color-error);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
.biometrics-mediainput__notice {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
align-items: flex-start;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-warning-light);
|
||
border: 1px solid var(--color-warning-border);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-primary);
|
||
font-size: var(--text-sm);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.biometrics-mediainput__notice > i {
|
||
color: var(--color-warning);
|
||
margin-top: 3px;
|
||
flex-shrink: 0;
|
||
}
|
||
.biometrics-mediainput__notice strong {
|
||
display: block;
|
||
margin-bottom: 2px;
|
||
}
|
||
.biometrics-mediainput__notice p {
|
||
margin: 0;
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.biometrics-mediainput__notice code {
|
||
background: var(--color-bg-tertiary);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.95em;
|
||
}
|
||
|
||
.biometrics-mediainput__preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-sm);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.biometrics-mediainput__preview img {
|
||
width: 100%;
|
||
max-height: 220px;
|
||
object-fit: contain;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.biometrics-mediainput__preview audio { width: 100%; }
|
||
|
||
.biometrics-mediainput__preview-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-mediainput__source-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-mediainput__clear {
|
||
background: transparent;
|
||
border: 0;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
min-width: 32px;
|
||
min-height: 32px;
|
||
border-radius: var(--radius-sm);
|
||
transition: color var(--duration-fast), background var(--duration-fast);
|
||
}
|
||
.biometrics-mediainput__clear:hover {
|
||
color: var(--color-error);
|
||
background: var(--color-error-light);
|
||
}
|
||
|
||
/* Fieldsets + chip toggles (attribute actions) */
|
||
.biometrics-fieldset {
|
||
border: 0;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-fieldset legend {
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
.biometrics-chipset {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
text-transform: capitalize;
|
||
transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast);
|
||
min-height: 32px;
|
||
}
|
||
.biometrics-chip input { position: absolute; opacity: 0; pointer-events: none; }
|
||
.biometrics-chip:hover { color: var(--color-text-primary); }
|
||
.biometrics-chip.active {
|
||
border-color: var(--color-accent-border);
|
||
background: var(--color-accent-light);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
/* Toggle switch */
|
||
.biometrics-switch {
|
||
display: inline-block;
|
||
position: relative;
|
||
width: 40px;
|
||
height: 22px;
|
||
flex-shrink: 0;
|
||
}
|
||
.biometrics-switch input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.biometrics-switch > span {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: var(--color-toggle-off);
|
||
border-radius: var(--radius-full);
|
||
transition: background var(--duration-fast);
|
||
cursor: pointer;
|
||
}
|
||
.biometrics-switch > span::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 2px;
|
||
top: 2px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
transition: transform var(--duration-fast);
|
||
box-shadow: var(--shadow-subtle);
|
||
}
|
||
.biometrics-switch input:checked + span { background: var(--color-accent); }
|
||
.biometrics-switch input:checked + span::after { transform: translateX(18px); }
|
||
.biometrics-switch input:focus-visible + span {
|
||
outline: 2px solid var(--color-border-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Split view for analyze (image + summary side) */
|
||
.biometrics-split {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 1fr);
|
||
gap: var(--spacing-md);
|
||
align-items: start;
|
||
}
|
||
@media (max-width: 980px) {
|
||
.biometrics-split { grid-template-columns: 1fr; }
|
||
}
|
||
.biometrics-split__media {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-split__aside {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Bounding box overlay */
|
||
.biometrics-bbox {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface-sunken);
|
||
overflow: hidden;
|
||
line-height: 0;
|
||
}
|
||
.biometrics-bbox img {
|
||
width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
}
|
||
.biometrics-bbox__box {
|
||
position: absolute;
|
||
border: 2px solid var(--color-accent);
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25), 0 0 12px rgba(232, 168, 124, 0.35);
|
||
pointer-events: none;
|
||
transition: border-color var(--duration-fast);
|
||
}
|
||
.biometrics-bbox__box.tone-default { border-color: var(--color-border-strong); box-shadow: none; }
|
||
.biometrics-bbox__box.tone-success { border-color: var(--color-success); }
|
||
.biometrics-bbox__box.tone-error { border-color: var(--color-error); }
|
||
.biometrics-bbox__box.tone-warning { border-color: var(--color-warning); }
|
||
.biometrics-bbox__tag {
|
||
position: absolute;
|
||
left: -2px;
|
||
top: -2px;
|
||
transform: translateY(-100%);
|
||
background: var(--color-bg-overlay);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-bottom: 0;
|
||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||
padding: 2px 8px;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-primary);
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
white-space: nowrap;
|
||
line-height: var(--leading-snug);
|
||
}
|
||
.biometrics-bbox__tag strong { font-weight: var(--font-weight-semibold); }
|
||
.biometrics-bbox__tag span { color: var(--color-text-secondary); }
|
||
|
||
.biometrics-facepicker {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-facepicker__chip {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
color: var(--color-text-secondary);
|
||
padding: 4px 12px;
|
||
border-radius: var(--radius-full);
|
||
cursor: pointer;
|
||
font-size: var(--text-xs);
|
||
font: inherit;
|
||
font-size: var(--text-xs);
|
||
min-height: 32px;
|
||
transition: border-color var(--duration-fast), color var(--duration-fast), background var(--duration-fast);
|
||
}
|
||
.biometrics-facepicker__chip:hover { color: var(--color-text-primary); }
|
||
.biometrics-facepicker__chip.active {
|
||
border-color: var(--color-accent-border);
|
||
background: var(--color-accent-light);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.biometrics-facepicker__chip small { margin-left: 4px; color: var(--color-text-muted); }
|
||
|
||
/* Summary card (dominant attributes) */
|
||
.biometrics-summary {
|
||
padding: var(--spacing-md);
|
||
}
|
||
.biometrics-summary__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.biometrics-summary__head h3 {
|
||
font-size: var(--text-base);
|
||
margin: 0;
|
||
font-weight: var(--font-weight-semibold);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-summary__head h3 i { color: var(--color-accent); }
|
||
.biometrics-summary__head h3 small {
|
||
color: var(--color-text-muted);
|
||
font-weight: var(--font-weight-regular);
|
||
font-size: var(--text-sm);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.biometrics-summary__grid {
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
column-gap: var(--spacing-md);
|
||
row-gap: 6px;
|
||
margin: 0;
|
||
}
|
||
.biometrics-summary__grid dt {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
align-self: center;
|
||
}
|
||
.biometrics-summary__grid dd {
|
||
margin: 0;
|
||
color: var(--color-text-primary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
/* Distribution bars */
|
||
.biometrics-dist {
|
||
padding: var(--spacing-md);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-dist__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.biometrics-dist__head h3 {
|
||
font-size: var(--text-sm);
|
||
margin: 0;
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: -0.005em;
|
||
}
|
||
.biometrics-dist__head i { color: var(--color-accent); }
|
||
.biometrics-dist__dominant {
|
||
margin-left: auto;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
text-transform: capitalize;
|
||
}
|
||
.biometrics-dist__rows {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.biometrics-dist__row {
|
||
display: grid;
|
||
grid-template-columns: minmax(80px, 110px) 1fr max-content;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.biometrics-dist__label {
|
||
color: var(--color-text-secondary);
|
||
text-transform: capitalize;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-dist__bar-wrap {
|
||
height: 6px;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-full);
|
||
overflow: hidden;
|
||
}
|
||
.biometrics-dist__bar {
|
||
height: 100%;
|
||
background: var(--color-text-muted);
|
||
border-radius: var(--radius-full);
|
||
transition: width var(--duration-normal) var(--ease-default);
|
||
}
|
||
.biometrics-dist__row.dominant .biometrics-dist__label { color: var(--color-text-primary); }
|
||
.biometrics-dist__row.dominant .biometrics-dist__bar { background: var(--color-accent); }
|
||
.biometrics-dist__value {
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.biometrics-dist__row.dominant .biometrics-dist__value { color: var(--color-text-primary); }
|
||
|
||
/* Pill chips (liveness) */
|
||
.biometrics-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px;
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
border: 1px solid var(--color-border-subtle);
|
||
background: var(--color-bg-secondary);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.biometrics-pill small {
|
||
color: var(--color-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.biometrics-pill.good {
|
||
background: var(--color-success-light);
|
||
border-color: var(--color-success-border);
|
||
color: var(--color-success);
|
||
}
|
||
.biometrics-pill.bad {
|
||
background: var(--color-error-light);
|
||
border-color: var(--color-error-border);
|
||
color: var(--color-error);
|
||
}
|
||
.biometrics-pill.muted { color: var(--color-text-muted); }
|
||
|
||
/* Compare view */
|
||
.biometrics-compare {
|
||
display: grid;
|
||
grid-template-columns: 1fr minmax(280px, 360px) 1fr;
|
||
gap: var(--spacing-md);
|
||
align-items: stretch;
|
||
}
|
||
@media (max-width: 1080px) {
|
||
.biometrics-compare { grid-template-columns: 1fr; }
|
||
}
|
||
.biometrics-compare__panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-compare__label {
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.biometrics-compare__center {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
justify-content: center;
|
||
}
|
||
.biometrics-compare__threshold {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
}
|
||
.biometrics-compare__threshold label {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
.biometrics-compare__threshold code {
|
||
color: var(--color-accent);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.biometrics-compare__threshold input[type="range"] {
|
||
width: 100%;
|
||
accent-color: var(--color-accent);
|
||
}
|
||
.biometrics-compare__hint {
|
||
margin: 0;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.biometrics-compare__hint code { color: var(--color-text-secondary); }
|
||
|
||
/* Match gauge */
|
||
.biometrics-gauge {
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-md);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
box-shadow: var(--shadow-subtle), var(--shadow-inset-top);
|
||
}
|
||
.biometrics-gauge__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-gauge__verdict {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: var(--text-lg);
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
.biometrics-gauge.tone-success .biometrics-gauge__verdict { color: var(--color-success); }
|
||
.biometrics-gauge.tone-error .biometrics-gauge__verdict { color: var(--color-error); }
|
||
.biometrics-gauge__confidence {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: var(--leading-tight);
|
||
}
|
||
.biometrics-gauge__confidence strong {
|
||
display: block;
|
||
font-size: var(--text-xl);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.biometrics-gauge__confidence span {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.biometrics-gauge__track {
|
||
position: relative;
|
||
height: 18px;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-full);
|
||
overflow: hidden;
|
||
}
|
||
.biometrics-gauge__zone {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
transition: width var(--duration-normal) var(--ease-default);
|
||
}
|
||
.biometrics-gauge__zone--match {
|
||
left: 0;
|
||
background: var(--color-success-light);
|
||
border-right: 1px dashed var(--color-success-border);
|
||
}
|
||
.biometrics-gauge__zone--miss {
|
||
background: var(--color-error-light);
|
||
}
|
||
.biometrics-gauge__threshold {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
background: var(--color-border-strong);
|
||
transform: translateX(-1px);
|
||
}
|
||
.biometrics-gauge__threshold span {
|
||
position: absolute;
|
||
bottom: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-muted);
|
||
letter-spacing: 0.08em;
|
||
padding: 1px 4px;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-gauge__marker {
|
||
position: absolute;
|
||
top: -4px;
|
||
bottom: -4px;
|
||
width: 12px;
|
||
transform: translateX(-6px);
|
||
background: var(--color-text-primary);
|
||
border-radius: 2px;
|
||
border: 2px solid var(--color-surface-raised);
|
||
transition: left var(--duration-normal) var(--ease-default);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.biometrics-gauge__marker span {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 9px;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-primary);
|
||
letter-spacing: 0.08em;
|
||
padding-top: 4px;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-gauge__footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.biometrics-gauge__footer em {
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
font-style: normal;
|
||
margin-right: 4px;
|
||
}
|
||
.biometrics-gauge__footer code {
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* Waveform */
|
||
.biometrics-waveform {
|
||
--biometrics-wave: var(--color-accent);
|
||
position: relative;
|
||
width: 100%;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
}
|
||
.biometrics-waveform--error {
|
||
padding: var(--spacing-md);
|
||
color: var(--color-error);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.biometrics-waveform__segment {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
background: rgba(232, 168, 124, 0.16);
|
||
border-left: 1px dashed var(--color-accent-border);
|
||
border-right: 1px dashed var(--color-accent-border);
|
||
pointer-events: none;
|
||
}
|
||
.biometrics-waveform__segment.tone-info { background: var(--color-info-light); border-color: var(--color-info-border); }
|
||
.biometrics-waveform__segment.tone-success { background: var(--color-success-light); border-color: var(--color-success-border); }
|
||
.biometrics-waveform__segment.tone-warning { background: var(--color-warning-light); border-color: var(--color-warning-border); }
|
||
.biometrics-waveform__segment.tone-accent { background: var(--color-accent-light); border-color: var(--color-accent-border); }
|
||
.biometrics-waveform__seglabel {
|
||
position: absolute;
|
||
top: 4px;
|
||
left: 4px;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-primary);
|
||
background: var(--color-bg-overlay);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-sm);
|
||
max-width: calc(100% - 8px);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-waveform__duration {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 6px;
|
||
font-size: 11px;
|
||
color: var(--color-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
background: var(--color-bg-overlay);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.biometrics-waveform__loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
/* Reusable waveform-and-playback component (audio transform / TTS / sound / traces) */
|
||
.audio-waveform-player {
|
||
--audio-wave: var(--color-primary);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
width: 100%;
|
||
}
|
||
.audio-waveform-player--dimmed .audio-waveform-player__canvas-wrap {
|
||
opacity: 0.7;
|
||
}
|
||
.audio-waveform-player__label {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.audio-waveform-player__canvas-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
}
|
||
.audio-waveform-player__error {
|
||
padding: var(--spacing-md);
|
||
color: var(--color-error);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.audio-waveform-player__segment {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
background: rgba(136, 192, 208, 0.16);
|
||
border-left: 1px dashed var(--color-primary);
|
||
border-right: 1px dashed var(--color-primary);
|
||
pointer-events: none;
|
||
}
|
||
.audio-waveform-player__seglabel {
|
||
position: absolute;
|
||
top: 4px;
|
||
left: 4px;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-primary);
|
||
background: var(--color-bg-overlay);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-sm);
|
||
max-width: calc(100% - 8px);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.audio-waveform-player__duration {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 6px;
|
||
font-size: 11px;
|
||
color: var(--color-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
background: var(--color-bg-overlay);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.audio-waveform-player__loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.audio-waveform-player__playhead {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 1.5px;
|
||
background: var(--color-primary);
|
||
opacity: 0.85;
|
||
pointer-events: none;
|
||
transform: translateX(-0.75px);
|
||
}
|
||
.audio-waveform-player__player {
|
||
width: 100%;
|
||
}
|
||
.audio-waveform-player__download {
|
||
align-self: flex-end;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
/* Spectrogram (AudioTransform spectral view) */
|
||
.audio-spectrogram-pair {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: var(--spacing-md);
|
||
}
|
||
@media (max-width: 720px) {
|
||
.audio-spectrogram-pair {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
.audio-spectrogram {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
.audio-spectrogram__label {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.audio-spectrogram__canvas-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
}
|
||
.audio-spectrogram__canvas-wrap--empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.audio-spectrogram__hint {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.audio-spectrogram__loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.audio-spectrogram__error {
|
||
padding: var(--spacing-md);
|
||
color: var(--color-error);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.audio-spectrogram__axis {
|
||
position: absolute;
|
||
left: 6px;
|
||
font-size: 10px;
|
||
color: var(--color-text-muted);
|
||
background: var(--color-bg-overlay);
|
||
padding: 0 4px;
|
||
border-radius: var(--radius-sm);
|
||
pointer-events: none;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.audio-spectrogram__axis--top {
|
||
top: 4px;
|
||
}
|
||
.audio-spectrogram__axis--bottom {
|
||
bottom: 4px;
|
||
}
|
||
.audio-spectrogram__duration {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 6px;
|
||
font-size: 11px;
|
||
color: var(--color-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
background: var(--color-bg-overlay);
|
||
padding: 1px 6px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
/* Audio Transform Studio tab */
|
||
.audio-transform-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
width: 100%;
|
||
}
|
||
.audio-transform-drop {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md);
|
||
border: 1px dashed var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-text-secondary);
|
||
cursor: default;
|
||
transition: border-color var(--duration-normal, 180ms) var(--ease-default, ease);
|
||
}
|
||
.audio-transform-drop--hover {
|
||
border-color: var(--color-primary);
|
||
background: var(--color-primary-light);
|
||
}
|
||
.audio-transform-drop__file {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
flex: 1;
|
||
font-family: var(--font-mono);
|
||
word-break: break-all;
|
||
}
|
||
.audio-transform-drop__pick {
|
||
cursor: pointer;
|
||
color: var(--color-primary);
|
||
text-decoration: underline;
|
||
margin-left: 4px;
|
||
}
|
||
.audio-transform-input {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.audio-transform-input__tabs {
|
||
display: inline-flex;
|
||
gap: 2px;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
padding: 2px;
|
||
align-self: flex-start;
|
||
}
|
||
.audio-transform-input__tab {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 4px 10px;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
}
|
||
.audio-transform-input__tab.active {
|
||
background: var(--color-primary-light);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.audio-transform-rec {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
align-items: flex-start;
|
||
padding: var(--spacing-md);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.audio-transform-rec__notice {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
align-items: center;
|
||
}
|
||
.audio-transform-rec__notice--error {
|
||
color: var(--color-error);
|
||
}
|
||
.audio-transform-rec__pending {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
}
|
||
.audio-transform-echo {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.audio-transform-echo__row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.audio-transform-echo__notice {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
align-items: flex-start;
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-info-light);
|
||
color: var(--color-text-primary);
|
||
border-left: 3px solid var(--color-info);
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
margin: 0;
|
||
}
|
||
.audio-transform-echo__notice > i {
|
||
color: var(--color-info);
|
||
margin-top: 2px;
|
||
}
|
||
.audio-transform-echo__elapsed {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* Enrollment layout (register + identify + list) */
|
||
.biometrics-enrollgrid {
|
||
display: grid;
|
||
grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
|
||
grid-template-areas:
|
||
"register identify"
|
||
"list list";
|
||
gap: var(--spacing-lg);
|
||
}
|
||
.biometrics-enrollgrid__register { grid-area: register; }
|
||
.biometrics-enrollgrid__identify { grid-area: identify; }
|
||
.biometrics-enrollgrid__list { grid-area: list; min-width: 0; }
|
||
@media (max-width: 980px) {
|
||
.biometrics-enrollgrid {
|
||
grid-template-columns: 1fr;
|
||
grid-template-areas:
|
||
"register"
|
||
"identify"
|
||
"list";
|
||
}
|
||
}
|
||
.biometrics-enrollgrid__register form,
|
||
.biometrics-enrollgrid__identify form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
}
|
||
.biometrics-enrollgrid__err {
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
|
||
.biometrics-enroll__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.biometrics-enroll__count {
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
padding: 2px 8px;
|
||
border-radius: var(--radius-full);
|
||
margin-left: var(--spacing-xs);
|
||
}
|
||
|
||
.biometrics-enroll__grid {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: var(--spacing-md);
|
||
}
|
||
.biometrics-enroll__card {
|
||
position: relative;
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-md);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
transition: border-color var(--duration-fast), transform var(--duration-fast);
|
||
}
|
||
.biometrics-enroll__card:hover {
|
||
border-color: var(--color-border-default);
|
||
transform: translateY(-1px);
|
||
}
|
||
.biometrics-enroll__card.highlight {
|
||
border-color: var(--color-accent-border);
|
||
box-shadow: 0 0 0 1px var(--color-accent-border);
|
||
animation: biometrics-highlight 1.4s ease-out;
|
||
}
|
||
@keyframes biometrics-highlight {
|
||
0% { box-shadow: 0 0 0 4px var(--color-accent-light); }
|
||
100% { box-shadow: 0 0 0 1px var(--color-accent-border); }
|
||
}
|
||
|
||
.biometrics-enroll__media {
|
||
aspect-ratio: 1 / 1;
|
||
background: var(--color-surface-sunken);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.biometrics-enroll__media img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.biometrics-enroll__media audio {
|
||
width: 90%;
|
||
}
|
||
.biometrics-enroll__initials {
|
||
font-size: 2rem;
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-muted);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.biometrics-enroll__body { display: flex; flex-direction: column; gap: 4px; }
|
||
.biometrics-enroll__name {
|
||
font-weight: var(--font-weight-semibold);
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-enroll__labels {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
.biometrics-enroll__labels li {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
background: var(--color-bg-secondary);
|
||
padding: 2px 6px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.biometrics-enroll__labels li span {
|
||
color: var(--color-text-muted);
|
||
margin-right: 4px;
|
||
}
|
||
.biometrics-enroll__meta {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.biometrics-enroll__delete {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
background: var(--color-bg-overlay);
|
||
border: 1px solid var(--color-border-subtle);
|
||
color: var(--color-text-muted);
|
||
border-radius: var(--radius-sm);
|
||
width: 28px;
|
||
height: 28px;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity var(--duration-fast), color var(--duration-fast), background var(--duration-fast);
|
||
}
|
||
.biometrics-enroll__card:hover .biometrics-enroll__delete,
|
||
.biometrics-enroll__card:focus-within .biometrics-enroll__delete { opacity: 1; }
|
||
.biometrics-enroll__delete:hover {
|
||
color: var(--color-error);
|
||
background: var(--color-error-light);
|
||
border-color: var(--color-error-border);
|
||
}
|
||
.biometrics-enroll__empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-xl);
|
||
border: 1px dashed var(--color-border-default);
|
||
border-radius: var(--radius-lg);
|
||
text-align: center;
|
||
color: var(--color-text-secondary);
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.biometrics-enroll__empty > i {
|
||
font-size: 2rem;
|
||
color: var(--color-accent);
|
||
opacity: 0.6;
|
||
}
|
||
.biometrics-enroll__empty p {
|
||
margin: 0;
|
||
max-width: 44ch;
|
||
line-height: var(--leading-normal);
|
||
font-size: var(--text-sm);
|
||
}
|
||
|
||
/* Matches list (identify results) */
|
||
.biometrics-matches {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-matches__empty {
|
||
padding: var(--spacing-md);
|
||
border: 1px dashed var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-muted);
|
||
text-align: center;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.biometrics-matches__row {
|
||
display: grid;
|
||
grid-template-columns: 32px 56px 1fr;
|
||
gap: var(--spacing-sm);
|
||
align-items: center;
|
||
padding: var(--spacing-sm);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.biometrics-matches__row.match { border-color: var(--color-success-border); }
|
||
.biometrics-matches__rank {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-align: center;
|
||
}
|
||
.biometrics-matches__avatar {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
background: var(--color-surface-sunken);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-muted);
|
||
font-weight: var(--font-weight-semibold);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.biometrics-matches__avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||
.biometrics-matches__body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||
.biometrics-matches__name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: var(--text-sm);
|
||
min-width: 0;
|
||
}
|
||
.biometrics-matches__name strong {
|
||
font-weight: var(--font-weight-semibold);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.biometrics-matches__badge {
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 2px 6px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-muted);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.biometrics-matches__badge.match {
|
||
background: var(--color-success-light);
|
||
color: var(--color-success);
|
||
}
|
||
.biometrics-matches__meter {
|
||
height: 4px;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-full);
|
||
overflow: hidden;
|
||
}
|
||
.biometrics-matches__fill {
|
||
height: 100%;
|
||
background: var(--color-accent);
|
||
transition: width var(--duration-normal) var(--ease-default);
|
||
}
|
||
.biometrics-matches__row.match .biometrics-matches__fill { background: var(--color-success); }
|
||
.biometrics-matches__meta {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.biometrics-matches__meta code {
|
||
color: var(--color-text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.biometrics-matches__preview { width: 100%; }
|
||
|
||
/* Embedding inspector */
|
||
.biometrics-embed {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md);
|
||
}
|
||
.biometrics-embed__head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
.biometrics-embed__title {
|
||
font-size: var(--text-base);
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
.biometrics-embed__meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-md);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
.biometrics-embed__meta strong { color: var(--color-text-primary); font-variant-numeric: tabular-nums; font-weight: var(--font-weight-semibold); }
|
||
.biometrics-embed__meta code { color: var(--color-text-secondary); }
|
||
|
||
/* Response details pane */
|
||
.biometrics-response {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
overflow: hidden;
|
||
}
|
||
.biometrics-response summary {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
cursor: pointer;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
list-style: none;
|
||
user-select: none;
|
||
min-height: 40px;
|
||
}
|
||
.biometrics-response summary::-webkit-details-marker { display: none; }
|
||
.biometrics-response summary i { transition: transform var(--duration-fast); }
|
||
.biometrics-response[open] summary i { transform: rotate(90deg); }
|
||
.biometrics-response pre {
|
||
margin: 0;
|
||
padding: var(--spacing-md);
|
||
background: var(--color-surface-sunken);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
overflow-x: auto;
|
||
max-height: 360px;
|
||
line-height: var(--leading-snug);
|
||
}
|
||
|
||
.form-label__hint {
|
||
color: var(--color-text-muted);
|
||
font-weight: var(--font-weight-regular);
|
||
margin-left: 4px;
|
||
}
|
||
|
||
/* ResourceRow — unified expandable row anatomy used by the Manage page so
|
||
installed models and backends share the same visual grammar as the Install
|
||
gallery (icon, name, description, badges, expandable detail panel). */
|
||
.resource-row { transition: background var(--duration-fast) var(--ease-default); }
|
||
.resource-row.is-dimmed { opacity: 0.55; transition: opacity 0.2s; }
|
||
.resource-row.is-expanded { background: var(--color-bg-tertiary); }
|
||
|
||
.resource-row__chevron-cell { width: 30px; }
|
||
.resource-row__icon-cell { width: 64px; }
|
||
|
||
.resource-row__icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid var(--color-border-subtle);
|
||
background: var(--color-bg-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
.resource-row__icon > img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.resource-row__icon > i {
|
||
font-size: 1.25rem;
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.resource-row__detail-row > .resource-row__detail-cell {
|
||
padding: 0;
|
||
background: var(--color-bg-primary);
|
||
border-top: 1px solid var(--color-border-subtle);
|
||
}
|
||
.resource-row__detail {
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
}
|
||
.resource-row__detail h4 {
|
||
font-size: var(--text-xs);
|
||
font-weight: 600;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-muted);
|
||
margin: 0 0 var(--spacing-sm) 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.resource-row__detail h4 i { color: var(--color-accent); }
|
||
.resource-row__detail-grid {
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
gap: 6px var(--spacing-md);
|
||
}
|
||
.resource-row__detail-grid dt {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
align-self: start;
|
||
padding-top: 2px;
|
||
}
|
||
.resource-row__detail-grid dd {
|
||
margin: 0;
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-primary);
|
||
min-width: 0;
|
||
word-break: break-word;
|
||
}
|
||
.resource-row__detail-md {
|
||
color: var(--color-text-secondary);
|
||
line-height: 1.6;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.resource-row__detail-md p:first-child { margin-top: 0; }
|
||
.resource-row__detail-md p:last-child { margin-bottom: 0; }
|
||
|
||
/* Description line directly under the row name — Install gallery already
|
||
does this; Manage rows used to show only the bare name. */
|
||
.resource-row__desc {
|
||
display: block;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
max-width: 42ch;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* ResourceActions — split lifecycle vs destructive with a thin divider so
|
||
the trash icon doesn't sit at the same eye-weight as the routine buttons.
|
||
Used now only for the rare row whose actions can't collapse into the
|
||
kebab menu (e.g. a "Protected" badge on system backends). */
|
||
.resource-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
justify-content: flex-end;
|
||
}
|
||
.resource-actions__group {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
.resource-actions__divider {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--color-border-subtle);
|
||
margin: 0 var(--spacing-xs);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ActionMenu — kebab trigger + popover menu. Borrows claudemaster's
|
||
restrained pattern: button reads as a quiet ellipsis at rest, lights up
|
||
on row hover, and the menu items hold typography-first weight (icon +
|
||
label, no fills until hover). The trigger stays at low opacity until the
|
||
user reaches for the row, so dense tables don't read as control panels. */
|
||
.action-menu__trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
font-size: var(--text-sm);
|
||
opacity: 0.45;
|
||
transition:
|
||
opacity var(--duration-fast) var(--ease-default),
|
||
color var(--duration-fast) var(--ease-default),
|
||
background var(--duration-fast) var(--ease-default),
|
||
border-color var(--duration-fast) var(--ease-default);
|
||
}
|
||
.resource-row:hover .action-menu__trigger,
|
||
.action-menu__trigger:focus-visible,
|
||
.action-menu__trigger.is-open {
|
||
opacity: 1;
|
||
}
|
||
.action-menu__trigger:hover,
|
||
.action-menu__trigger.is-open {
|
||
background: var(--color-bg-tertiary);
|
||
border-color: var(--color-border-subtle);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.action-menu__trigger:focus-visible {
|
||
outline: 2px solid var(--color-border-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
.action-menu__trigger--compact {
|
||
width: 24px;
|
||
height: 24px;
|
||
font-size: var(--text-xs);
|
||
}
|
||
|
||
.action-menu {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 200px;
|
||
padding: 4px;
|
||
outline: none;
|
||
}
|
||
.action-menu__item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: 8px 10px;
|
||
border: 0;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--color-text-primary);
|
||
font-family: inherit;
|
||
font-size: var(--text-sm);
|
||
font-weight: 500;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
transition: background var(--duration-fast) var(--ease-default),
|
||
color var(--duration-fast) var(--ease-default);
|
||
}
|
||
.action-menu__item.is-active,
|
||
.action-menu__item:hover:not(:disabled) {
|
||
background: var(--color-bg-tertiary);
|
||
}
|
||
.action-menu__item:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.45;
|
||
}
|
||
.action-menu__icon {
|
||
width: 14px;
|
||
text-align: center;
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-xs);
|
||
flex-shrink: 0;
|
||
}
|
||
.action-menu__item.is-active .action-menu__icon,
|
||
.action-menu__item:hover:not(:disabled) .action-menu__icon {
|
||
color: var(--color-text-primary);
|
||
}
|
||
.action-menu__label {
|
||
flex: 1;
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.action-menu__shortcut {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
color: var(--color-text-muted);
|
||
letter-spacing: 0.04em;
|
||
flex-shrink: 0;
|
||
}
|
||
.action-menu__item.is-danger { color: var(--color-error); }
|
||
.action-menu__item.is-danger .action-menu__icon { color: var(--color-error); }
|
||
.action-menu__item.is-danger:hover:not(:disabled),
|
||
.action-menu__item.is-danger.is-active {
|
||
background: var(--color-error-light);
|
||
color: var(--color-error);
|
||
}
|
||
.action-menu__item.is-danger:hover:not(:disabled) .action-menu__icon,
|
||
.action-menu__item.is-danger.is-active .action-menu__icon {
|
||
color: var(--color-error);
|
||
}
|
||
.action-menu__divider {
|
||
height: 1px;
|
||
background: var(--color-border-subtle);
|
||
margin: 4px 2px;
|
||
}
|
||
.action-menu__badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: 8px 10px;
|
||
font-size: var(--text-xs);
|
||
font-weight: 500;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.action-menu__badge > i { color: var(--color-text-muted); font-size: var(--text-xs); width: 14px; text-align: center; }
|
||
|
||
/* Pulse modifier — subtle ring on the Pin button when a model is pinned, so
|
||
pinning is visible at a glance without a second icon next to the name. */
|
||
.btn--pulse {
|
||
animation: btnPulseWarning 1.6s ease-in-out infinite;
|
||
}
|
||
@keyframes btnPulseWarning {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(235, 203, 139, 0); }
|
||
50% { box-shadow: 0 0 0 4px rgba(235, 203, 139, 0.18); }
|
||
}
|
||
|
||
/* StatCard clickable variant — the Manage summary cards double as shortcuts
|
||
to the relevant tab + filter, so the card needs a real button affordance. */
|
||
.stat-card[data-clickable="true"] {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.stat-card[data-clickable="true"]:hover {
|
||
border-color: var(--stat-accent, var(--color-border));
|
||
transform: translateY(-1px);
|
||
}
|
||
.stat-card[data-clickable="true"]:focus-visible {
|
||
outline: 2px solid var(--color-border-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Manage summary marker — same .stat-grid layout. Top margin separates the
|
||
cards from the System Resources card above (otherwise they sit too close
|
||
to the RAM bar) and bottom margin tightens the gap to the tabs below. */
|
||
.manage-summary {
|
||
margin-top: var(--spacing-xl);
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
|
||
/* Screen-reader-only label, used for table headers whose visual cell needs
|
||
no label (kebab-only Actions column, toggle-only Enabled column). The
|
||
header still announces correctly to assistive tech without making the
|
||
table feel like it's labelling sparse columns twice. */
|
||
.visually-hidden {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
|
||
/* Reduced motion accessibility */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
}
|
||
|
||
/* Admin console layout (pathless route) - structural only */
|
||
/* Lazy-route loading fallback. Delayed ~150ms so fast chunk loads don't flash. */
|
||
.route-fallback {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
min-height: 40vh;
|
||
opacity: 0;
|
||
animation: route-fallback-in var(--duration-normal) var(--ease-out, ease-out) 150ms forwards;
|
||
}
|
||
@keyframes route-fallback-in { to { opacity: 1; } }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.route-fallback { opacity: 1; animation: none; }
|
||
}
|
||
|
||
/* Console layout: a secondary rail panel (Build, Operate) beside the page body. */
|
||
.console-layout {
|
||
display: flex;
|
||
gap: var(--spacing-lg);
|
||
align-items: flex-start;
|
||
padding: var(--spacing-md);
|
||
}
|
||
.console-rail {
|
||
flex: 0 0 220px;
|
||
position: sticky;
|
||
top: var(--spacing-md);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
padding: var(--spacing-sm);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-subtle);
|
||
}
|
||
/* Entrance only when entering the console (not on item-to-item sub-nav). */
|
||
.console-rail.console-rail--enter {
|
||
animation: console-rail-in var(--duration-normal) var(--ease-out, ease-out) both;
|
||
}
|
||
@keyframes console-rail-in {
|
||
from { opacity: 0; transform: translateX(-8px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
.console-rail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xs);
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.console-rail-header__title { display: inline-flex; align-items: center; gap: var(--spacing-sm); }
|
||
.console-rail-header__title i { color: var(--color-primary); font-size: 0.9rem; }
|
||
.console-rail-toggle {
|
||
display: none;
|
||
width: 34px;
|
||
height: 34px;
|
||
place-items: center;
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface-elevated);
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
.console-rail-toggle:hover { border-color: var(--color-border-strong); color: var(--color-text-primary); }
|
||
.console-rail-groups { display: flex; flex-direction: column; gap: var(--spacing-xs); }
|
||
.console-group { display: flex; flex-direction: column; gap: 1px; }
|
||
.console-group + .console-group {
|
||
margin-top: var(--spacing-xs);
|
||
padding-top: var(--spacing-xs);
|
||
border-top: 1px solid var(--color-border-divider);
|
||
}
|
||
.console-group-title {
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-tertiary);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
}
|
||
.console-rail .nav-item {
|
||
margin: 0;
|
||
padding: 7px var(--spacing-sm);
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
transition: color var(--duration-fast) var(--ease-out, ease-out),
|
||
background var(--duration-fast) var(--ease-out, ease-out),
|
||
transform var(--duration-fast) var(--ease-out, ease-out);
|
||
}
|
||
.console-rail .nav-item:hover:not(.active) { transform: translateX(2px); }
|
||
.console-rail .nav-item.active { box-shadow: none; }
|
||
.console-rail .nav-item.active .nav-icon { color: var(--color-primary); }
|
||
.console-body {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.console-layout { flex-direction: column; padding: var(--spacing-sm); }
|
||
.console-rail { position: static; flex-basis: auto; width: 100%; }
|
||
.console-rail-toggle { display: grid; }
|
||
.console-rail-groups { display: none; }
|
||
.console-rail--open .console-rail-groups { display: flex; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.console-rail.console-rail--enter { animation: none; }
|
||
.console-rail .nav-item:hover:not(.active) { transform: none; }
|
||
}
|
||
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.status-pill__dot { width: 7px; height: 7px; border-radius: var(--radius-full); background: var(--color-text-muted); flex-shrink: 0; }
|
||
.status-pill--success .status-pill__dot { background: var(--color-success); }
|
||
.status-pill--warning .status-pill__dot { background: var(--color-warning); }
|
||
.status-pill--error .status-pill__dot { background: var(--color-error); }
|
||
.status-pill--info .status-pill__dot { background: var(--color-info); }
|
||
.status-pill--muted .status-pill__dot { background: var(--color-text-muted); }
|
||
|
||
/* ──────────────────── Voice Library ──────────────────── */
|
||
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
|
||
.voice-library-page,
|
||
.voice-create-page {
|
||
width: 100%;
|
||
max-width: var(--page-max-default);
|
||
margin: 0 auto;
|
||
padding: var(--spacing-md) var(--spacing-md) var(--spacing-xl);
|
||
animation: fadeIn var(--duration-normal) var(--ease-default);
|
||
}
|
||
|
||
.voice-library-page .page-header,
|
||
.voice-create-page .page-header {
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
|
||
.voice-library-page .page-title i,
|
||
.voice-create-page .page-title i {
|
||
color: var(--color-primary);
|
||
margin-right: var(--spacing-xs);
|
||
}
|
||
|
||
.voice-library-summary {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
min-height: 36px;
|
||
margin: calc(-1 * var(--spacing-sm)) 0 var(--spacing-md);
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.voice-library-summary strong {
|
||
color: var(--color-text-primary);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-base);
|
||
}
|
||
.voice-library-summary__divider {
|
||
width: 1px;
|
||
height: 18px;
|
||
background: var(--color-border-default);
|
||
}
|
||
|
||
.voice-library-error {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.voice-library-shell {
|
||
display: grid;
|
||
grid-template-columns: minmax(300px, 0.78fr) minmax(430px, 1.35fr);
|
||
min-height: 620px;
|
||
overflow: hidden;
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-sm), var(--shadow-inset-top);
|
||
}
|
||
|
||
.voice-library-master {
|
||
min-width: 0;
|
||
border-right: 1px solid var(--color-border-divider);
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
|
||
.voice-library-toolbar {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md);
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
}
|
||
.voice-library-search {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
}
|
||
.voice-library-search i {
|
||
position: absolute;
|
||
left: 12px;
|
||
color: var(--color-text-tertiary);
|
||
font-size: var(--text-xs);
|
||
pointer-events: none;
|
||
}
|
||
.voice-library-search input {
|
||
width: 100%;
|
||
height: 38px;
|
||
padding: 0 12px 0 34px;
|
||
color: var(--color-text-primary);
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-md);
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.voice-library-search input::placeholder { color: var(--color-text-tertiary); }
|
||
.voice-library-language { width: 138px; height: 38px; font-size: var(--text-sm); }
|
||
|
||
.voice-library-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: min(70vh, 760px);
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
}
|
||
.voice-library-loading {
|
||
min-height: 260px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-sm);
|
||
color: var(--color-text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.voice-library-empty { min-height: 430px; border: 0; background: transparent; }
|
||
|
||
.voice-row {
|
||
appearance: none;
|
||
width: 100%;
|
||
display: grid;
|
||
grid-template-columns: 38px minmax(0, 1fr) 14px;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-md);
|
||
color: var(--color-text-primary);
|
||
text-align: left;
|
||
background: transparent;
|
||
border: 0;
|
||
border-bottom: 1px solid var(--color-border-divider);
|
||
cursor: pointer;
|
||
transition: background var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default);
|
||
}
|
||
.voice-row:hover { background: var(--color-surface-hover); }
|
||
.voice-row--selected {
|
||
background: var(--color-primary-light);
|
||
box-shadow: inset 2px 0 0 var(--color-primary);
|
||
}
|
||
.voice-row--selected:hover { background: var(--color-primary-light); }
|
||
.voice-row__avatar {
|
||
width: 38px;
|
||
height: 38px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-surface-elevated);
|
||
border: 1px solid var(--color-border-default);
|
||
color: var(--color-primary);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.65rem;
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.voice-row--selected .voice-row__avatar {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary-text);
|
||
}
|
||
.voice-row__content { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
||
.voice-row__head { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: var(--spacing-sm); }
|
||
.voice-row__head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); }
|
||
.voice-row__head > span { flex-shrink: 0; color: var(--color-text-tertiary); font-family: var(--font-mono); font-size: 0.625rem; }
|
||
.voice-row__waveform {
|
||
height: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
overflow: hidden;
|
||
}
|
||
.voice-row__waveform > span {
|
||
flex: 1 1 auto;
|
||
max-width: 3px;
|
||
min-width: 1px;
|
||
border-radius: 1px;
|
||
background: var(--color-border-strong);
|
||
}
|
||
.voice-row--selected .voice-row__waveform > span { background: var(--color-primary); opacity: 0.72; }
|
||
.voice-row__meta { display: flex; align-items: center; gap: 5px; color: var(--color-text-tertiary); font-size: 0.65rem; }
|
||
.voice-row__chevron { color: var(--color-text-tertiary); font-size: 0.65rem; }
|
||
.voice-row--selected .voice-row__chevron { color: var(--color-primary); }
|
||
|
||
.voice-library-detail {
|
||
min-width: 0;
|
||
padding: clamp(var(--spacing-lg), 3vw, var(--spacing-xl));
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--color-surface-raised);
|
||
}
|
||
.voice-library-detail > .empty-state { flex: 1; min-height: 420px; }
|
||
.voice-detail__header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--spacing-lg); margin-bottom: var(--spacing-lg); }
|
||
.voice-detail__eyebrow {
|
||
display: block;
|
||
margin-bottom: var(--spacing-xs);
|
||
color: var(--color-eyebrow);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.625rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.16em;
|
||
}
|
||
.voice-detail__header h2 { margin: 0; font-size: clamp(var(--text-xl), 2.5vw, var(--text-2xl)); line-height: var(--leading-tight); }
|
||
.voice-detail__header p { margin: var(--spacing-xs) 0 0; max-width: 54ch; color: var(--color-text-secondary); line-height: var(--leading-normal); }
|
||
.voice-detail__player {
|
||
padding: var(--spacing-md);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.voice-detail__section { margin-top: var(--spacing-lg); }
|
||
.voice-detail__section h3 { margin: 0 0 var(--spacing-sm); color: var(--color-text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em; }
|
||
.voice-detail__section blockquote {
|
||
margin: 0;
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
border-left: 2px solid var(--color-primary);
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-relaxed);
|
||
}
|
||
.voice-detail__metadata {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 1px;
|
||
margin: var(--spacing-lg) 0 0;
|
||
overflow: hidden;
|
||
border: 1px solid var(--color-border-divider);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-border-divider);
|
||
}
|
||
.voice-detail__metadata > div { min-width: 0; padding: var(--spacing-sm) var(--spacing-md); background: var(--color-bg-secondary); }
|
||
.voice-detail__metadata dt { color: var(--color-text-tertiary); font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
||
.voice-detail__metadata dd { margin: 3px 0 0; overflow: hidden; text-overflow: ellipsis; color: var(--color-text-primary); font-size: var(--text-sm); }
|
||
.voice-detail__consent {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
margin-top: var(--spacing-lg);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
color: var(--color-success);
|
||
background: var(--color-success-light);
|
||
border: 1px solid var(--color-success-border);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.voice-detail__consent > i { width: 20px; text-align: center; }
|
||
.voice-detail__consent > div { display: flex; flex-direction: column; }
|
||
.voice-detail__consent strong { font-size: var(--text-sm); }
|
||
.voice-detail__consent span { color: var(--color-text-secondary); font-size: var(--text-xs); }
|
||
.voice-detail__model-setup {
|
||
margin-top: var(--spacing-lg);
|
||
padding: var(--spacing-md);
|
||
border: 1px solid var(--color-warning-border);
|
||
border-radius: var(--radius-lg);
|
||
background: var(--color-warning-light);
|
||
}
|
||
.voice-detail__model-setup-heading { display: flex; align-items: flex-start; gap: var(--spacing-sm); }
|
||
.voice-detail__model-setup-heading > i { width: 24px; padding-top: 2px; color: var(--color-warning); text-align: center; }
|
||
.voice-detail__model-setup-heading h3 { margin: 0; font-size: var(--text-sm); }
|
||
.voice-detail__model-setup-heading p { margin: 3px 0 0; color: var(--color-text-secondary); font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.voice-detail__model-loading { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-md) 0 var(--spacing-xs) 32px; color: var(--color-text-secondary); font-size: var(--text-xs); }
|
||
.voice-detail__model-list { display: flex; flex-direction: column; gap: var(--spacing-xs); margin: var(--spacing-md) 0 0 32px; padding: 0; list-style: none; }
|
||
.voice-detail__model-list li {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-sm);
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.voice-detail__model-list li > span { min-width: 0; display: flex; flex-direction: column; }
|
||
.voice-detail__model-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-xs); }
|
||
.voice-detail__model-list small { color: var(--color-text-tertiary); font-family: var(--font-mono); font-size: 0.625rem; }
|
||
.voice-detail__model-list .btn { flex-shrink: 0; }
|
||
.voice-detail__model-fallback { margin: var(--spacing-md) 0 0 32px; color: var(--color-text-secondary); font-size: var(--text-xs); }
|
||
.voice-detail__model-fallback a { color: var(--color-primary); font-weight: var(--font-weight-semibold); }
|
||
.voice-detail__capability-note { margin: var(--spacing-sm) 0 0 32px; color: var(--color-text-tertiary); font-size: 0.625rem; line-height: var(--leading-normal); }
|
||
.voice-detail__capability-note i { color: var(--color-success); }
|
||
|
||
.voice-detail__api {
|
||
margin-top: var(--spacing-lg);
|
||
overflow: hidden;
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-lg);
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.voice-detail__api > summary {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-md);
|
||
cursor: pointer;
|
||
list-style: none;
|
||
}
|
||
.voice-detail__api > summary::-webkit-details-marker { display: none; }
|
||
.voice-detail__api > summary > span { display: flex; flex-direction: column; }
|
||
.voice-detail__api > summary strong { font-size: var(--text-sm); }
|
||
.voice-detail__api > summary small { margin-top: 2px; color: var(--color-text-tertiary); font-size: var(--text-xs); }
|
||
.voice-detail__api > summary > i { color: var(--color-text-tertiary); font-size: var(--text-xs); transition: transform var(--duration-fast) var(--ease-default); }
|
||
.voice-detail__api[open] > summary > i { transform: rotate(180deg); }
|
||
.voice-detail__api-body { padding: var(--spacing-md); border-top: 1px solid var(--color-border-divider); background: var(--color-surface-raised); }
|
||
.voice-detail__api-body > p { margin: 0; color: var(--color-text-secondary); font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.voice-detail__compatible-models { display: flex; flex-direction: column; gap: var(--spacing-xs); margin-top: var(--spacing-md); }
|
||
.voice-detail__compatible-models > strong { color: var(--color-text-secondary); font-size: var(--text-xs); }
|
||
.voice-detail__compatible-models > span { color: var(--color-text-tertiary); font-size: var(--text-xs); }
|
||
.voice-detail__compatible-models ul { display: flex; flex-wrap: wrap; gap: var(--spacing-xs); margin: 0; padding: 0; list-style: none; }
|
||
.voice-detail__compatible-models li { display: inline-flex; align-items: center; gap: var(--spacing-xs); padding: 4px 8px; background: var(--color-success-light); border: 1px solid var(--color-success-border); border-radius: var(--radius-full); font-size: var(--text-xs); }
|
||
.voice-detail__compatible-models li small { color: var(--color-text-tertiary); font-family: var(--font-mono); font-size: 0.6rem; }
|
||
.voice-detail__code-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm); margin-top: var(--spacing-md); color: var(--color-text-secondary); font-size: var(--text-xs); font-weight: var(--font-weight-semibold); }
|
||
.voice-detail__api pre { max-width: 100%; margin: var(--spacing-xs) 0 0; padding: var(--spacing-md); overflow-x: auto; background: var(--color-bg-primary); border: 1px solid var(--color-border-divider); border-radius: var(--radius-md); color: var(--color-text-primary); font-size: 0.6875rem; line-height: var(--leading-relaxed); }
|
||
.voice-detail__api code { font-family: var(--font-mono); }
|
||
.voice-detail__api-body > .voice-detail__api-note { margin-top: var(--spacing-sm); color: var(--color-text-tertiary); }
|
||
.voice-detail__actions { display: flex; align-items: center; gap: var(--spacing-sm); margin-top: auto; padding-top: var(--spacing-xl); }
|
||
.voice-detail__actions .btn-danger { margin-left: auto; }
|
||
|
||
/* Compact profile picker inside the existing TTS control column. */
|
||
.tts-voice-label-row { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm); }
|
||
.tts-voice-label-row .badge { margin-bottom: var(--spacing-xs); }
|
||
.tts-voice-picker { display: flex; flex-direction: column; gap: var(--spacing-xs); }
|
||
.tts-voice-picker__selection {
|
||
display: grid;
|
||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm);
|
||
color: var(--color-text-primary);
|
||
background: var(--color-primary-light);
|
||
border: 1px solid var(--color-primary-border);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.tts-voice-picker__avatar {
|
||
width: 30px;
|
||
height: 30px;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--color-primary-text);
|
||
background: var(--color-primary);
|
||
border-radius: var(--radius-full);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.58rem;
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
.tts-voice-picker__selection > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; }
|
||
.tts-voice-picker__selection strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); }
|
||
.tts-voice-picker__selection small { color: var(--color-text-secondary); font-size: var(--text-xs); }
|
||
.tts-voice-picker__selection > i { color: var(--color-primary); }
|
||
.tts-voice-picker__hint,
|
||
.tts-voice-picker__error { margin: 0; color: var(--color-text-muted); font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.tts-voice-picker__error { color: var(--color-error); }
|
||
.tts-voice-picker__hint a,
|
||
.tts-voice-picker__manage { color: var(--color-primary); font-weight: var(--font-weight-medium); text-decoration: none; }
|
||
.tts-voice-picker__manage { display: inline-flex; align-items: center; gap: 5px; width: fit-content; font-size: var(--text-xs); }
|
||
.tts-voice-picker__manage i { font-size: 0.65em; transition: transform var(--duration-fast) var(--ease-default); }
|
||
.tts-voice-picker__manage:hover i { transform: translateX(2px); }
|
||
|
||
/* Full-page creation flow: the content form and a quiet sticky readiness rail. */
|
||
.voice-create-page { max-width: 1180px; padding-top: var(--spacing-xl); }
|
||
.voice-create-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: var(--spacing-lg); align-items: start; }
|
||
.voice-create-form {
|
||
overflow: hidden;
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-sm), var(--shadow-inset-top);
|
||
}
|
||
.voice-create-section { padding: var(--spacing-xl); border-bottom: 1px solid var(--color-border-divider); }
|
||
.voice-create-section__heading { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
|
||
.voice-create-section__heading > span {
|
||
padding-top: 3px;
|
||
color: var(--color-primary);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.voice-create-section__heading h2 { margin: 0; color: var(--color-text-primary); font-size: var(--text-lg); }
|
||
.voice-create-section__heading p { margin: 4px 0 0; max-width: 62ch; color: var(--color-text-muted); font-size: var(--text-sm); line-height: var(--leading-normal); }
|
||
.voice-create-processing { display: flex; align-items: center; gap: var(--spacing-sm); margin-top: var(--spacing-sm); color: var(--color-text-secondary); font-size: var(--text-sm); }
|
||
.form-error { margin: var(--spacing-sm) 0 0; color: var(--color-error); font-size: var(--text-sm); }
|
||
.form-hint { margin: var(--spacing-xs) 0 0; color: var(--color-text-tertiary); font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.voice-create-preview { margin-top: var(--spacing-md); padding: var(--spacing-md); background: var(--color-surface-sunken); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-lg); }
|
||
.voice-create-preview__meta { display: flex; flex-wrap: wrap; gap: var(--spacing-xs) var(--spacing-md); margin-top: var(--spacing-sm); color: var(--color-text-muted); font-size: var(--text-xs); }
|
||
.voice-create-preview__meta span { display: inline-flex; align-items: center; gap: 5px; }
|
||
.voice-create-preview__meta .tone-success { color: var(--color-success); }
|
||
.voice-create-preview__meta .tone-warning { color: var(--color-warning); }
|
||
.voice-create-label-row { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-md); }
|
||
.voice-create-label-row > span { color: var(--color-text-tertiary); font-family: var(--font-mono); font-size: 0.625rem; }
|
||
|
||
.voice-consent-check { position: relative; display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--spacing-md); align-items: start; padding: var(--spacing-md); background: var(--color-bg-tertiary); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); cursor: pointer; }
|
||
.voice-consent-check:hover { border-color: var(--color-border-strong); }
|
||
.voice-consent-check > input { position: absolute; opacity: 0; pointer-events: none; }
|
||
.voice-consent-check__box { width: 22px; height: 22px; display: grid; place-items: center; color: transparent; background: var(--color-surface-sunken); border: 1px solid var(--color-border-strong); border-radius: var(--radius-sm); transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast); }
|
||
.voice-consent-check > input:checked + .voice-consent-check__box { color: var(--color-primary-text); background: var(--color-primary); border-color: var(--color-primary); }
|
||
.voice-consent-check > input:focus-visible + .voice-consent-check__box { box-shadow: 0 0 0 3px var(--color-focus-ring); }
|
||
.voice-consent-check strong { display: block; color: var(--color-text-primary); font-size: var(--text-sm); }
|
||
.voice-consent-check small { display: block; margin-top: 3px; color: var(--color-text-secondary); font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.voice-create-actions { display: flex; justify-content: flex-end; gap: var(--spacing-sm); padding: var(--spacing-lg) var(--spacing-xl); background: var(--color-bg-secondary); }
|
||
|
||
.voice-readiness {
|
||
position: sticky;
|
||
top: var(--spacing-lg);
|
||
padding: var(--spacing-lg);
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-default);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: var(--shadow-subtle), var(--shadow-inset-top);
|
||
}
|
||
.voice-readiness__header { display: flex; align-items: flex-start; gap: var(--spacing-sm); padding-bottom: var(--spacing-md); border-bottom: 1px solid var(--color-border-divider); }
|
||
.voice-readiness__icon { width: 34px; height: 34px; flex: 0 0 34px; display: grid; place-items: center; color: var(--color-primary); background: var(--color-primary-light); border-radius: var(--radius-lg); }
|
||
.voice-readiness__header h2 { margin: 0; font-size: var(--text-base); }
|
||
.voice-readiness__header p { margin: 3px 0 0; color: var(--color-text-muted); font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.voice-readiness ul { display: flex; flex-direction: column; gap: var(--spacing-xs); list-style: none; padding: var(--spacing-md) 0; margin: 0; }
|
||
.voice-readiness__item { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: var(--spacing-sm); min-height: 30px; color: var(--color-text-muted); font-size: var(--text-sm); }
|
||
.voice-readiness__item i { width: 20px; height: 20px; display: grid; place-items: center; border-radius: var(--radius-full); background: var(--color-surface-sunken); font-size: 0.6rem; }
|
||
.voice-readiness__item--ready { color: var(--color-text-primary); }
|
||
.voice-readiness__item--ready i { color: var(--color-success); background: var(--color-success-light); }
|
||
.voice-readiness__item--warning i { color: var(--color-warning); background: var(--color-warning-light); }
|
||
.voice-readiness__privacy { display: flex; align-items: flex-start; gap: var(--spacing-sm); padding-top: var(--spacing-md); border-top: 1px solid var(--color-border-divider); color: var(--color-text-muted); }
|
||
.voice-readiness__privacy i { margin-top: 2px; color: var(--color-primary); }
|
||
.voice-readiness__privacy p { margin: 0; font-size: var(--text-xs); line-height: var(--leading-normal); }
|
||
.voice-readiness__privacy strong { color: var(--color-text-secondary); }
|
||
|
||
@media (max-width: 1120px) {
|
||
.voice-library-shell { grid-template-columns: 1fr; }
|
||
.voice-library-master { border-right: 0; border-bottom: 1px solid var(--color-border-divider); }
|
||
.voice-library-list { max-height: 420px; }
|
||
.voice-create-grid { grid-template-columns: 1fr; }
|
||
.voice-readiness { position: static; order: -1; }
|
||
.voice-readiness ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.voice-library-page,
|
||
.voice-create-page { padding: var(--spacing-sm); }
|
||
.voice-library-page .page-header__meta,
|
||
.voice-create-page .page-header__meta { width: 100%; }
|
||
.voice-library-page .page-header__meta .btn,
|
||
.voice-create-page .page-header__meta .btn { width: 100%; }
|
||
.voice-library-summary { align-items: flex-start; flex-direction: column; gap: var(--spacing-xs); }
|
||
.voice-library-summary__divider { display: none; }
|
||
.voice-library-toolbar { grid-template-columns: 1fr; }
|
||
.voice-library-language { width: 100%; }
|
||
.voice-detail__header { flex-direction: column; }
|
||
.voice-detail__metadata { grid-template-columns: 1fr; }
|
||
.voice-detail__model-list,
|
||
.voice-detail__model-fallback,
|
||
.voice-detail__capability-note { margin-left: 0; }
|
||
.voice-detail__model-list li { align-items: stretch; flex-direction: column; }
|
||
.voice-detail__model-list .btn { width: 100%; }
|
||
.voice-detail__actions { align-items: stretch; flex-direction: column; }
|
||
.voice-detail__actions .btn { width: 100%; }
|
||
.voice-detail__actions .btn-danger { margin-left: 0; }
|
||
.voice-create-section { padding: var(--spacing-lg); }
|
||
.voice-create-section__heading { grid-template-columns: 1fr; gap: var(--spacing-xs); }
|
||
.voice-create-actions { padding: var(--spacing-md) var(--spacing-lg); }
|
||
.voice-create-actions .btn { flex: 1; }
|
||
.voice-readiness { order: initial; }
|
||
.voice-readiness ul { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* Nodes: cluster pulse + attention callout (replaces the stat-card strip) */
|
||
.cluster-pulse {
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
margin: 0 0 var(--spacing-lg);
|
||
}
|
||
.cluster-pulse__strong { color: var(--color-text-primary); font-weight: 600; }
|
||
|
||
.attention-callout {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
border-radius: var(--radius-md);
|
||
margin-bottom: var(--spacing-lg);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.attention-callout--warn {
|
||
background: var(--color-warning-light);
|
||
border: 1px solid var(--color-warning-border);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.attention-callout--error {
|
||
background: var(--color-error-light);
|
||
border: 1px solid var(--color-error-border);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
/* Node roster panels (Nodes page) */
|
||
.node-roster { display: flex; flex-direction: column; gap: var(--spacing-sm); }
|
||
.node-panel {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
.node-panel__main { padding: var(--spacing-md) var(--spacing-lg); cursor: pointer; }
|
||
.node-panel:hover { border-color: var(--color-border); }
|
||
.node-panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--spacing-md); }
|
||
.node-panel__id { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; }
|
||
.node-panel__name { font-weight: 600; }
|
||
.node-panel__meta { display: flex; gap: var(--spacing-lg); margin-top: var(--spacing-sm); color: var(--color-text-muted); font-size: var(--text-xs); }
|
||
.node-panel__models { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--spacing-sm); }
|
||
.model-chip {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
font-family: var(--font-mono); font-size: 0.6875rem;
|
||
padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid;
|
||
}
|
||
.model-chip__dot { width: 6px; height: 6px; border-radius: 50%; }
|
||
.model-chip__state { opacity: 0.85; font-style: normal; }
|
||
.node-filter { margin-bottom: var(--spacing-lg); }
|
||
.node-detail__metrics { display: flex; gap: var(--spacing-xl); margin: var(--spacing-md) 0 var(--spacing-lg); flex-wrap: wrap; }
|
||
|
||
/* Rendered Markdown ---------------------------------------------------------
|
||
Gallery descriptions, backend descriptions and voice notes are all
|
||
author-supplied Markdown pasted from model cards. Left unscoped they inherit
|
||
browser defaults, so a description that opens with `#` renders a 2em heading
|
||
inside a 13px surface while a `##` further down is indistinguishable from
|
||
body text. This maps the whole element set onto the UI type scale so any
|
||
description reads as part of the app rather than as a document dropped into
|
||
it. Apply it to every element fed by renderMarkdown(). */
|
||
.markdown-body {
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--text-sm);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.markdown-body > :first-child { margin-top: 0; }
|
||
.markdown-body > :last-child { margin-bottom: 0; }
|
||
|
||
.markdown-body h1,
|
||
.markdown-body h2,
|
||
.markdown-body h3,
|
||
.markdown-body h4,
|
||
.markdown-body h5,
|
||
.markdown-body h6 {
|
||
color: var(--color-text-primary);
|
||
font-weight: var(--font-weight-semibold);
|
||
line-height: var(--leading-tight);
|
||
margin: var(--spacing-md) 0 var(--spacing-xs);
|
||
}
|
||
.markdown-body h1 { font-size: var(--text-lg); }
|
||
.markdown-body h2 { font-size: var(--text-base); }
|
||
.markdown-body h3 { font-size: var(--text-sm); }
|
||
/* Below h3 the scale has no room left to separate levels by size, so the
|
||
remaining depth is carried by case and weight instead. */
|
||
.markdown-body h4,
|
||
.markdown-body h5,
|
||
.markdown-body h6 {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.markdown-body p { margin: 0 0 var(--spacing-sm); }
|
||
.markdown-body ul,
|
||
.markdown-body ol {
|
||
margin: 0 0 var(--spacing-sm);
|
||
padding-left: var(--spacing-lg);
|
||
}
|
||
.markdown-body li { margin-bottom: var(--spacing-xs); }
|
||
.markdown-body li:last-child { margin-bottom: 0; }
|
||
.markdown-body li > ul,
|
||
.markdown-body li > ol { margin-top: var(--spacing-xs); }
|
||
|
||
.markdown-body a {
|
||
color: var(--color-primary);
|
||
text-decoration: underline;
|
||
text-underline-offset: 2px;
|
||
}
|
||
.markdown-body a:hover { color: var(--color-primary-hover); }
|
||
|
||
.markdown-body code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.9em;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-sm);
|
||
padding: 1px 4px;
|
||
}
|
||
.markdown-body pre {
|
||
margin: 0 0 var(--spacing-sm);
|
||
padding: var(--spacing-sm);
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-border-subtle);
|
||
border-radius: var(--radius-md);
|
||
overflow-x: auto;
|
||
}
|
||
.markdown-body pre code {
|
||
background: none;
|
||
padding: 0;
|
||
font-size: var(--text-xs);
|
||
}
|
||
|
||
.markdown-body blockquote {
|
||
margin: 0 0 var(--spacing-sm);
|
||
padding-left: var(--spacing-sm);
|
||
border-left: 2px solid var(--color-border-strong);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.markdown-body img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.markdown-body hr {
|
||
border: 0;
|
||
border-top: 1px solid var(--color-border-divider);
|
||
margin: var(--spacing-md) 0;
|
||
}
|
||
/* Tables are the one element that can exceed the measure, so they scroll in
|
||
their own box rather than widening the surface around them. */
|
||
.markdown-body table {
|
||
display: block;
|
||
width: max-content;
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
border-collapse: collapse;
|
||
margin: 0 0 var(--spacing-sm);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.markdown-body th,
|
||
.markdown-body td {
|
||
border: 1px solid var(--color-border-subtle);
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
text-align: left;
|
||
}
|
||
.markdown-body th {
|
||
background: var(--color-bg-tertiary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
/* Prose block in a detail pane. The label/value table beside it is right for
|
||
scalars, but multi-paragraph prose in a value cell runs the full pane width
|
||
and swamps the rows it sits above, so the description gets its own
|
||
full-width block with a reading measure. */
|
||
.detail-prose {
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
.detail-prose__label {
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
.detail-prose__body {
|
||
max-width: 68ch;
|
||
}
|
||
|
||
/* Variant list --------------------------------------------------------------
|
||
One row per alternative build of the same model. Names vary in length, so
|
||
the rows share the parent's tracks and line up as columns; the name leads
|
||
because it is what the reader is choosing between. Only the informative
|
||
status is badged: "fits" is true of nearly every row and says nothing, while
|
||
a build that does not fit, and the one that plain Install would pick, do. */
|
||
.variant-list {
|
||
display: grid;
|
||
width: 100%;
|
||
/* info, name, backend, quantization, size, status, action, filler.
|
||
Every informative track is content-sized and the filler takes the slack,
|
||
so the rows stay packed together rather than stranding the install
|
||
affordance an inch of empty space away from the name it acts on. The
|
||
grid still spans the pane, which is what lets a revealed detail panel
|
||
have the full width its file table needs. */
|
||
grid-template-columns: max-content minmax(0, auto) max-content max-content max-content max-content max-content 1fr;
|
||
column-gap: var(--spacing-md);
|
||
row-gap: 2px;
|
||
}
|
||
/* One variant: the info control, the install row, and the details the control
|
||
reveals. It spans the list's tracks and passes them down, so the install row
|
||
still shares its columns with every other row despite the extra nesting. */
|
||
.variant-entry {
|
||
display: grid;
|
||
grid-column: 1 / -1;
|
||
grid-template-columns: subgrid;
|
||
align-items: center;
|
||
}
|
||
.variant-row {
|
||
display: grid;
|
||
/* Stops short of the filler track so the hover and focus box hugs the row's
|
||
own content rather than trailing across the empty right-hand side. */
|
||
grid-column: 2 / -2;
|
||
grid-template-columns: subgrid;
|
||
align-items: center;
|
||
width: 100%;
|
||
margin: 0;
|
||
padding: var(--spacing-xs) var(--spacing-sm);
|
||
font: inherit;
|
||
text-align: left;
|
||
color: inherit;
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
transition: background-color var(--duration-fast) var(--ease-default),
|
||
border-color var(--duration-fast) var(--ease-default);
|
||
}
|
||
/* Subgrid keeps the columns shared across rows; without it each row falls back
|
||
to sizing its own tracks, which is ragged but still legible. */
|
||
@supports not (grid-template-columns: subgrid) {
|
||
.variant-entry { grid-template-columns: max-content minmax(22ch, auto) max-content max-content max-content max-content max-content 1fr; }
|
||
.variant-row { grid-template-columns: minmax(22ch, auto) max-content max-content max-content max-content max-content; }
|
||
}
|
||
.variant-row:hover:not(:disabled) {
|
||
background: var(--color-bg-hover);
|
||
border-color: var(--color-border-default);
|
||
}
|
||
.variant-row:focus-visible {
|
||
outline: 2px solid var(--color-focus-ring);
|
||
outline-offset: 1px;
|
||
}
|
||
.variant-row:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
}
|
||
.variant-row__name {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.variant-row__backend,
|
||
.variant-row__size {
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
/* Monospaced like the name above it, because a weight format is a literal
|
||
token and Q4_K_M vs Q4_K_S has to be told apart at a glance in a column. */
|
||
.variant-row__quant {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
/* An entry naming no weight format is a real state, not a gap: it recedes to
|
||
the muted colour and drops the mono face, so it reads as prose saying
|
||
"unknown" rather than as a token. */
|
||
.variant-row__quant--unknown {
|
||
font-family: inherit;
|
||
font-style: italic;
|
||
color: var(--color-text-disabled);
|
||
}
|
||
.variant-row__size {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.variant-row__status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
flex-wrap: wrap;
|
||
}
|
||
.variant-row__action {
|
||
color: var(--color-text-disabled);
|
||
font-size: var(--text-xs);
|
||
transition: color var(--duration-fast) var(--ease-default);
|
||
}
|
||
.variant-row:hover:not(:disabled) .variant-row__action,
|
||
.variant-row:focus-visible .variant-row__action {
|
||
color: var(--color-primary);
|
||
}
|
||
/* A build that does not fit stays installable, an explicit choice being an
|
||
override the server honours, but it should not read as a peer of the ones
|
||
that do. */
|
||
.variant-row--unfit .variant-row__name {
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
/* Quiet until asked for: the row's job is to be compared and installed, so the
|
||
way into "everything about this one" recedes until the eye or the keyboard
|
||
lands on it. */
|
||
.variant-row__info {
|
||
grid-column: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 1.75rem;
|
||
height: 1.75rem;
|
||
margin: 0;
|
||
padding: 0;
|
||
font-size: var(--text-xs);
|
||
color: var(--color-text-disabled);
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
transition: color var(--duration-fast) var(--ease-default),
|
||
background-color var(--duration-fast) var(--ease-default);
|
||
}
|
||
.variant-row__info:hover,
|
||
.variant-entry:hover .variant-row__info {
|
||
color: var(--color-primary);
|
||
}
|
||
.variant-row__info:hover {
|
||
background: var(--color-bg-hover);
|
||
}
|
||
.variant-row__info:focus-visible {
|
||
outline: 2px solid var(--color-focus-ring);
|
||
outline-offset: 1px;
|
||
color: var(--color-primary);
|
||
}
|
||
.variant-row__info[aria-expanded="true"] {
|
||
color: var(--color-primary);
|
||
background: var(--color-bg-hover);
|
||
border-color: var(--color-border-default);
|
||
}
|
||
|
||
/* The third level of disclosure on this page, so it leans on an inset and a
|
||
rule rather than another card: a nested panel with its own border inside an
|
||
expanded row inside an expanded table reads as debris. */
|
||
.variant-detail {
|
||
grid-column: 1 / -1;
|
||
min-width: 0;
|
||
margin: var(--spacing-xs) 0 var(--spacing-sm) var(--spacing-lg);
|
||
border-left: 2px solid var(--color-border-default);
|
||
background: var(--color-surface-sunken);
|
||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||
overflow: hidden;
|
||
}
|
||
.variant-detail__state {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
font-size: var(--text-sm);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.variant-detail__state--error {
|
||
color: var(--color-error);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.variant-row,
|
||
.variant-row__info,
|
||
.variant-row__action { transition: none; }
|
||
}
|