diff --git a/STUDIO.md b/STUDIO.md index 8ae06e8..57d93a4 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -75,3 +75,29 @@ Build-Einstellungen und Update-Ansicht geöffnet, deaktivierte Aktionsknöpfe so Layout kontrolliert. Die Testvorschau verwendet einen eigenen Origin, sodass keine Entwürfe der eigentlichen Anwendung überschrieben wurden. Keine SSH-/Modell- oder Build-Aktion für diese GUI-Erweiterung ausgeführt. + +## Modellgalerie und Download-Ablauf (2026-09-28) + +Die Live-Galerie verwendet eine eigene Deck-Oberfläche, inspiriert von LocalAIs +[Modellgalerie](https://github.com/mudler/LocalAI/blob/master/core/http/react-ui/src/pages/Models.jsx) +und [Download-Anzeige](https://github.com/mudler/LocalAI/blob/master/core/http/react-ui/src/components/OperationCard.jsx). +Es wurde kein LocalAI-Frontendcode übernommen. + +- Beim Öffnen erscheinen beliebte Hugging-Face-Repositories des Bereichs. +- Suche liefert bis zu 20 Treffer; Sortierung nach Downloads oder Namen. +- Ergebnis auswählen: Modelldetails, Lizenzlink und feste Quellrevision. +- Dateien nach Name/Quantisierungsbezeichnung und Format filtern; genau eine Datei auswählen. +- Download explizit starten. Zugangsbeschränkte Repositories, bereits vorhandene + Dateien, laufende Downloads und unzureichender Speicher sperren den Button. +- Fortschritt, Bytes, Abbruch und Fehler werden aus der vorhandenen API angezeigt. +- Bibliothek zeigt abgeschlossene Dateien einschließlich Version und Integritätsprüfung. + +Grenzen: weiterhin Einzeldateien, keine vollständige Pipeline-Installation, +keine Warteschlange, kein Resume, keine automatischen Modellstarts. Audio-Suche +ist derzeit auf Text-to-Speech begrenzt. Hardware-/Kontext-Eignung wird in dieser +Galerie noch nicht berechnet; Dateigröße wird ausdrücklich nicht als VRAM-Bedarf +angezeigt. Der separate llama.cpp-Einpassungstest bleibt unter Einstellungen. + +Validierung: 46 bestehende Python-Tests bestanden; JavaScript-Syntax geprüft; +Browserprüfung mit echten Hugging-Face-Such- und Dateidaten, Dateifilter, +Auswahl und Speicherreserve-Sperre. Keine Modellgewichte für den UI-Test geladen. diff --git a/catalog-ui.js b/catalog-ui.js index 8cb4760..6375d19 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -1,15 +1,88 @@ -const CatalogUI=(()=>{ - const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const size=n=>(n/1024**3).toFixed(2)+' GiB'; - async function api(path,data){const r=await fetch('/api/v1/catalog'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} - function html(library){return `
LIVE · HUGGING FACE

${library?'Dateien auf Athena':'Modelle suchen'}

Öffentliche Repositories. Downloads laden kein Modell in RAM oder VRAM. Für Bild, Audio und Video können mehrere Dateien und ein eigener Worker nötig sein.

${library?'':''}

`;} - function bind(kind,library){ - const root=document.querySelector('#live-catalog');if(!root)return; - const message=t=>{if(root.isConnected)root.querySelector('#hub-message').textContent=t;}; - async function refresh(){try{const v=await api('');if(!root.isConnected)return;const j=v.job;root.querySelector('#hub-job').innerHTML=`

Freier Speicher: ${size(v.free_bytes)} · Reserve: 10 GiB

`+(j?`

${e(j.repo)} / ${e(j.file)} · ${e(j.state)} · ${size(j.bytes)} / ${size(j.total)}

${j.error?`

${e(j.error)}

`:''}${j.state==='downloading'?'':''}`:'');root.querySelector('#hub-cancel')?.addEventListener('click',async()=>{await api('/cancel',{});message('Abbruch angefordert; laufende Netzwerkabfrage kann noch bis zu 20 Sekunden dauern.');});if(library)root.querySelector('#hub-results').innerHTML=v.entries.filter(x=>x.kind===kind).map(x=>`

${e(x.repo)}

${e(x.file)} · ${size(x.size)}

Heruntergeladen · Laufzeit noch nicht angebunden

Revision ${e(x.revision)} · ${x.upstream_hash_verified?'SHA-256 mit Quelle geprüft':'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}
`).join('')||'

Noch keine Dateien für diesen Bereich heruntergeladen.

';if(j?.state==='downloading')setTimeout(()=>{if(root.isConnected)refresh();},2000);}catch(err){message(err.message);}} - root.querySelector('#hub-refresh').onclick=refresh; - root.querySelector('#hub-search')?.addEventListener('submit',async event=>{event.preventDefault();message('Suche …');try{const v=await api('/search?'+new URLSearchParams({q:new FormData(event.target).get('q'),kind}));if(!root.isConnected)return;root.querySelector('#hub-results').innerHTML=v.models.map(m=>`

${e(m.repo)}

${m.gated?'Zugangsbeschränkt':'Öffentlich'} · ${e(m.downloads)} Downloads

`).join('');message(v.models.length+' Ergebnisse');root.querySelectorAll('[data-repo]').forEach(b=>b.onclick=()=>files(b.dataset.repo));}catch(err){message(err.message);}}); - async function files(repo){message('Dateien werden gelesen …');try{const v=await api('/files?'+new URLSearchParams({repo}));if(!root.isConnected)return;root.querySelector('#hub-files').innerHTML=`

${e(repo)}

Lizenz: ${e(v.license||'Nicht angegeben')} · Modellkarte und Bedingungen lesen

VRAM- und Kontext-Eignung noch nicht berechnet. Dateigröße allein belegt keine Lauffähigkeit.

`+v.files.map((f,i)=>`

${e(f.name)} · ${size(f.size)}

`).join('');message(v.files.length+' unterstützte Dateien (GGUF, Safetensors, JSON).');root.querySelectorAll('[data-file]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/download',{repo,filename:v.files[Number(b.dataset.file)].name,revision:v.revision,kind});message('Download gestartet. Kein Modell wird ausgeführt.');refresh();}catch(err){message(err.message);}finally{b.disabled=false;}});}catch(err){message(err.message);}} +/* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */ +window.CatalogUI = (() => { + const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const size = n => { if (!Number.isFinite(n)) return 'Unbekannt'; if(n < 1024) return n+' B'; const unit = n >= 1024**3 ? 'GiB' : n >= 1024**2 ? 'MiB' : 'KiB'; return (n / (unit === 'GiB' ? 1024**3 : unit === 'MiB' ? 1024**2 : 1024)).toFixed(1)+' '+unit; }; + const number = n => Number.isFinite(n) ? new Intl.NumberFormat('de-DE', {notation:'compact'}).format(n) : '–'; + async function api(path, data) { + const response = await fetch('/api/v1/catalog'+path, data ? {method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)} : {}); + const result = await response.json(); if (!response.ok) throw Error(result.error || 'Anfrage fehlgeschlagen'); return result; + } + function html(library) { + return `
HUGGING FACE · LIVE-KATALOG

${library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}

${library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}

+

+ ${library ? '
' : `
Beliebte Modelle
01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN

Welches Modell interessiert dich?

Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.

Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.

`}
`; + } + function bind(kind, library) { + const root = document.querySelector('#live-catalog'); if (!root) return; + const el = id => root.querySelector('#'+id); + let state = {entries:[],job:null,free_bytes:null}, models = [], selected = null, detail = null, timer, refreshing = false, searchSequence = 0, detailSequence = 0, starting = false, libraryRendered = false; + const message = text => { if (root.isConnected) el('hub-message').textContent = text; }; + const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name); + function renderModels() { + const sorted = [...models].sort(el('hub-sort').value === 'name' ? (a,b) => a.repo.localeCompare(b.repo) : (a,b) => (b.downloads||0)-(a.downloads||0)); + el('hub-count').textContent = `${models.length} Ergebnisse · höchstens 20 pro Suche`; + el('hub-results').innerHTML = sorted.map(m => ``).join('') || '

Keine Treffer

Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.

'; + root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); + } + function renderLibrary() { + el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
DATEI HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

Laufzeit noch nicht angebunden

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; + } + function renderJob() { + el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`; + const j = state.job; if (!j) { el('hub-job').innerHTML = ''; return; } + const labels = {downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen'}; + const percent = j.total > 0 ? Math.min(100,Math.max(0,j.bytes/j.total*100)) : 0; + el('hub-job').innerHTML = `
${escape(labels[j.state] || j.state)}${percent.toFixed(1)} %

${escape(j.repo)}

${escape(j.file)}

${size(j.bytes)} von ${size(j.total)}${j.state === 'complete' ? ' · geprüft und in der Bibliothek gespeichert' : ''}

${j.error ? `

${escape(j.error)}

` : ''}${j.state === 'downloading' ? '' : ''}
`; + el('hub-cancel')?.addEventListener('click', async event => { event.target.disabled = true; try { await api('/cancel',{}); message('Abbruch angefordert. Eine laufende Netzwerkabfrage kann noch bis zu 20 Sekunden dauern.'); } catch(error) { message(error.message); event.target.disabled = false; } }); + } + async function refresh() { + if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer); + try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; } } else { if (entriesChanged) renderModels(); updateSelection(); } } + catch(error) { message(error.message); } + finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); } + } + async function search(q) { + const sequence = ++searchSequence; message('Modelle werden gesucht …'); + try { const result = await api('/search?'+new URLSearchParams({q,kind})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); message(kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : 'Ergebnisse nach Beliebtheit. Wähle ein Modell für die Dateiauswahl.'); } + catch(error) { if (sequence === searchSequence) message(error.message); } + } + function updateSelection() { + if (!detail || !el('hub-download')) return; + const chosen = el('hub-variants').querySelector('input:checked'); + const file = chosen ? detail.files[Number(chosen.value)] : null; + const busy = starting || state.job?.state === 'downloading'; + const exists = file && installed(file); + const fitsDisk = file && Number.isFinite(state.free_bytes) && file.size <= state.free_bytes - 10*1024**3; + el('hub-download').disabled = !file || !!detail.gated || busy || exists || !fitsDisk; + el('hub-download').textContent = starting ? 'Download wird vorbereitet …' : exists ? 'Bereits in Bibliothek' : busy ? 'Ein Download läuft bereits' : 'Ausgewählte Datei herunterladen'; + el('hub-selection').textContent = file ? `${file.name} · ${size(file.size)}${!fitsDisk ? ' · Speicherplatz noch nicht bestätigt oder Reserve unterschritten' : ''}` : 'Wähle eine Datei aus der Liste.'; + } + function renderFiles() { + const query = el('hub-file-filter').value.toLowerCase(), format = el('hub-format').value; + const current = el('hub-variants').querySelector('input:checked')?.value; + const files = detail.files.map((f,index) => ({...f,index})).filter(f => f.name.toLowerCase().includes(query) && (!format || f.name.toLowerCase().endsWith(format))).sort((a,b) => Number(a.name.toLowerCase().endsWith('.json'))-Number(b.name.toLowerCase().endsWith('.json')) || a.size-b.size); + el('hub-variants').innerHTML = files.map(f => ``).join('') || '

Keine passenden Dateien. Unterstützt werden GGUF, Safetensors und JSON.

'; + el('hub-variants').onchange = updateSelection; updateSelection(); + } + async function loadFiles(repo) { + const sequence = ++detailSequence; selected = repo; detail = null; renderModels(); + el('hub-files').innerHTML = '

Modelldetails werden geladen …

'; + try { + const result = await api('/files?'+new URLSearchParams({repo})); if (!root.isConnected || sequence !== detailSequence) return; detail = result; + el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Hardware-Eignung noch nicht ermittelt

Dateigröße ist kein VRAM-Bedarf. Kontext, Quantisierung und Laufzeit müssen gemeinsam geprüft werden.

${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; + el('hub-file-filter').oninput = renderFiles; el('hub-format').onchange = renderFiles; renderFiles(); + el('hub-download').onclick = async () => { + const chosen = el('hub-variants').querySelector('input:checked'); if (!chosen || starting) return; + const request = {repo:detail.repo,revision:detail.revision,filename:detail.files[Number(chosen.value)].name,kind}; + starting = true; updateSelection(); + try { await api('/download', request); message('Download gestartet. Den Fortschritt siehst du oberhalb der Galerie.'); await refresh(); } + catch(error) { message(error.message); } + finally { starting = false; if (root.isConnected) updateSelection(); } + }; + } catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '

Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.

'; message(error.message); } } + } + el('hub-refresh').onclick = refresh; + if (!library) { el('hub-sort').onchange = renderModels; el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); } refresh(); } return {html,bind}; diff --git a/style.css b/style.css index e25f409..c9bb790 100644 --- a/style.css +++ b/style.css @@ -6,3 +6,9 @@ label{display:block;margin:18px 0 8px;font-size:13px;color:#c4d1c6}input,select{ aside{overflow-y:auto}nav{gap:3px}nav a{padding:11px 12px}.prototype-banner{display:flex;align-items:center;gap:14px;background:#202b23;border:1px solid #40503f;border-radius:9px;padding:15px 18px;margin:24px 0;color:#bdd0ba;font-size:12px}.studio-tabs{display:flex;gap:7px;border-bottom:1px solid #344038;padding-bottom:10px;margin:28px 0}.studio-tabs button{font-size:13px}.studio-tabs button span{margin-left:8px;opacity:.6}.model-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.model-card h2{margin-bottom:8px}.model-icon{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;background:#29372b;color:#d0e5c4;border-radius:10px;font-size:23px}.model-facts{font-size:12px;line-height:1.6}.model-facts>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #2a3430}.model-facts dt{color:#819386}.model-facts dd{margin:0;text-align:right;color:#c9d3ca}.unknown-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#a2aea5;margin-right:5px}.card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:22px}.card-actions button{margin-right:0;font-size:12px}.studio-toolbar{display:grid;grid-template-columns:2fr 1fr;gap:18px}.studio-toolbar label{margin-top:0}.studio-toolbar input,.studio-toolbar select{max-width:none;margin-bottom:0}.small{font-size:12px}.section-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:20px}.section-heading h2{margin-bottom:3px}.section-heading p{margin-top:5px}.section-heading button{white-space:nowrap}.profile-list{display:grid;gap:12px}.profile-row{display:grid;grid-template-columns:1.5fr 1fr .5fr 1fr;gap:18px;align-items:center;padding:22px}.profile-row h2{font-size:18px;margin:9px 0 4px;overflow-wrap:anywhere}.profile-row p{font-size:12px;margin:0}.profile-row strong{display:block;margin-top:10px;font-size:14px;font-weight:500}.profile-actions{grid-column:1/-1;display:flex;border-top:1px solid #303a37;padding-top:14px}.profile-actions button{font-size:12px;margin-bottom:0}.endpoint-preview,.detail-card,.editor{margin-top:24px}pre{background:#101718;padding:20px;border-radius:8px;overflow:auto;font-size:12px;line-height:1.6;color:#bed4b8;max-height:280px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.form-grid input,.form-grid select{max-width:none}.checkbox{display:flex;align-items:center;gap:10px}.checkbox input{width:16px;height:16px;margin:0;accent-color:#bfdda9}.editor details{border-top:1px solid #303a37;margin-top:22px;padding-top:18px}summary{cursor:pointer;color:#ccdbc7;font-size:14px}.danger{color:#e9aaa0!important}#studio-notice{color:#c7e4b6}#profile-error{color:#ffc1a7}.runtime-empty{text-align:center;padding:40px;margin-bottom:20px}.runtime-empty a{display:block;margin-top:16px;font-size:13px}.flow{list-style:none;counter-reset:steps;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:20px;margin:28px 0}.flow li{counter-increment:steps;padding:0 0 0 35px;position:relative}.flow li:before{content:counter(steps,decimal-leading-zero);position:absolute;left:0;color:#b6d2a8;font-size:12px;top:2px}.flow b{font-size:13px;font-weight:500}.flow span{display:block;color:#82968a;font-size:12px;line-height:1.7;margin-top:7px}.runtime-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:22px}.runtime-stats .card{padding:22px}.runtime-stats h2{font-size:18px}.runtime-stats p{font-size:12px}#studio[data-page=runtime]>.card{margin-top:20px}.release-comparison{display:flex;gap:35px;align-items:center;margin:25px 0}.fit-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}.fit-grid b{font-size:13px}.fit-grid p{font-size:12px}@media(max-width:1100px){.model-grid{grid-template-columns:1fr}.runtime-stats{grid-template-columns:1fr}.profile-row{grid-template-columns:1fr 1fr}.fit-grid{grid-template-columns:1fr}}@media(max-width:600px){.form-grid,.studio-toolbar,.flow{grid-template-columns:1fr}.prototype-banner,.section-heading{align-items:flex-start;flex-direction:column}.studio-tabs{flex-wrap:wrap}.studio-tabs button{padding:10px 12px}.profile-row{gap:18px}.card{padding:20px}.model-facts>div{flex-direction:column;gap:3px}.model-facts dd{text-align:left}} .nav-heading{font-size:10px;letter-spacing:1.5px;color:#75867b;margin:15px 12px 5px} + +/* Live model catalogue: browse, inspect a file, download, then library. */ +.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} +.hub-search{display:flex;align-items:end;gap:12px;margin:24px 0}.hub-search label{flex:1;margin:0}.hub-search input{max-width:none;margin-bottom:0}.hub-search button{margin:0} +.hub-layout{display:grid;grid-template-columns:minmax(240px,.8fr) minmax(0,1.4fr);gap:24px;align-items:start}.hub-list-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.hub-list-heading select{width:auto;max-width:160px;font-size:12px;margin:0;padding:8px}.hub-results{display:grid;gap:8px;max-height:850px;overflow-y:auto;padding:3px}.hub-model{display:grid;gap:9px;width:100%;margin:0;text-align:left;padding:18px;background:#181f21;color:#e4eee5;border:1px solid #303a37}.hub-model:hover{background:#222d27}.hub-model.selected{border-color:#bddda9;background:#26342b}.hub-model strong{font-size:14px;overflow-wrap:anywhere}.hub-author,.hub-model-meta{font-size:11px;color:#96a89c}.hub-detail{min-width:0}.hub-detail h2{font-size:22px}.hub-badges{display:flex;gap:8px;flex-wrap:wrap}.hub-fit{padding:16px;background:#202a25;border-radius:9px;font-size:13px;margin:22px 0}.hub-fit p{font-size:12px;margin-bottom:0}.hub-file-controls{display:grid;grid-template-columns:1.3fr 1fr;gap:12px}.hub-file-controls label{margin-top:0}.hub-file-controls input,.hub-file-controls select{font-size:12px;margin-bottom:0}.hub-variants{max-height:380px;overflow:auto;border-top:1px solid #303a37}.hub-variant{display:flex;align-items:center;gap:12px;padding:16px 8px;margin:0;border-bottom:1px solid #303a37;cursor:pointer}.hub-variant:has(input:checked){background:#28362b}.hub-variant input{width:16px;min-width:16px;margin:0;accent-color:#c5edaf}.hub-variant span{flex:1;min-width:0}.hub-variant strong{display:block;font-weight:500;font-size:12px;overflow-wrap:anywhere}.hub-variant small{display:block;margin-top:7px;font-size:10px;color:#95a69b}.hub-variant b{font-size:11px;white-space:nowrap}.hub-review{margin-top:22px}.hub-review button{width:100%;font-size:13px}.hub-filename{overflow-wrap:anywhere}.hub-operation{margin-top:20px;border-color:#647e58}.hub-operation h3{font-size:16px}.hub-operation p{margin:10px 0;font-size:12px}.hub-operation progress{height:9px}.hub-operation .card-top strong{font-size:20px} +@media(max-width:1100px){.hub-layout{grid-template-columns:1fr}.hub-results{max-height:360px}.hub-file-controls{grid-template-columns:1fr 1fr}}@media(max-width:600px){.hub-search{align-items:stretch;flex-direction:column}.hub-file-controls{grid-template-columns:1fr}.hub-variant{gap:8px}}