diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c2751a..4d5057f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
# Changelog — FlowDeck
+## v4.12.1 (2026-09-05) — Corrections configuration Agent & IA + AI Meeting Note
+
+> Correctifs UI sur la section **Settings → Agent & IA** et remplacement de l'ancien bouton
+> « AI meeting note » (qui insérait du texte) par une vraie **boîte de réunion interactive**.
+
+### Fix
+- **Champs « Clé API »** : le `x-text` embarquait le SVG de l'icône œil dans l'attribut HTML, ce qui
+ cassait l'attribut et laissait du texte résiduel (`sk-…': '👁'" style="top:50%;">`). Les boutons
+ d'affichage/masquage utilisent désormais deux `` avec l'icône en contenu.
+- **Champs « URL API »** : pré-remplis avec l'URL réellement utilisée par l'application (base par
+ défaut du fournisseur, ex. `https://api.openai.com/v1`) quand aucune URL personnalisée n'est
+ stockée — exposée via `base_url` dans `/api/agent/providers` (fournisseur par défaut + au
+ changement de provider).
+
+### Changed
+- **AI Meeting Note** : le bouton « Get started » ne génère plus du texte via l'agent. Il crée un
+ bloc **`meeting`** interactif dans la page (page renommée « Meeting @date » si vide) :
+ - Étape 1 — consentement (moi-même / message audio auto) + « Start transcribing » ;
+ - Étape 2 — transcription **live** via Web Speech API (fr-FR, horodatée), boutons Pause / Stop,
+ fallback « sans audio » (transcript à saisir/coller) si le navigateur ne supporte pas ;
+ - Étape 3 — onglets Notes / Transcript / Summary, bouton « Générer le résumé » (helper headless
+ `window.fdAgent.generate` sur le FlowDeck Agent) produisant Résumé / Décisions / Action items,
+ copie presse-papiers et « Nouvelle transcription ».
+ - Persistance du bloc et de son état dans la page (champ `meeting` ajouté aux blocs sauvegardés).
+
+### Infra
+- `VERSION` → 4.12.1 ; `app/main.py` version → 4.12.1.
+
## v4.12.0 (2026-09-05) — Agent & IA : refonte des réglages, fournisseurs activés/fonctionnels dans le panneau, contexte document & guide
> Refonte de la section **Settings → Agent & IA** pour la rendre plus claire, et aligne le panneau
diff --git a/VERSION b/VERSION
index 815588e..53cf85e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.12.0
+4.12.1
diff --git a/app/main.py b/app/main.py
index a0991c9..b33952d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
- version="4.12.0",
+ version="4.12.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
diff --git a/app/services/llm_config.py b/app/services/llm_config.py
index 8c3c0f1..3c4d5c2 100644
--- a/app/services/llm_config.py
+++ b/app/services/llm_config.py
@@ -126,9 +126,13 @@ def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> N
def provider_info() -> list[dict]:
- """Providers list for the UI: known models + whether an API key is required."""
+ """Providers list for the UI: known models + whether an API key is required.
+
+ ``base_url`` is the endpoint the application uses by default for this
+ provider's chat requests (shown in the Settings "URL API" fields).
+ """
out: list[dict] = []
- for name, (_base, default_model) in PROVIDERS.items():
+ for name, (base, default_model) in PROVIDERS.items():
models = list(PROVIDER_MODELS.get(name) or ())
if default_model and default_model not in models:
models.insert(0, default_model)
@@ -137,6 +141,7 @@ def provider_info() -> list[dict]:
"name": name.capitalize(),
"default_model": default_model,
"models": models,
+ "base_url": (base or ""),
"requires_key": name not in ("offline", "ollama"),
})
return out
diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html
index d990b29..73e0c77 100644
--- a/app/templates/_page_editor_scripts.html
+++ b/app/templates/_page_editor_scripts.html
@@ -204,6 +204,7 @@
get isOpen(){return SM._o;}};
function renderBlock(b,idx){
+ if(b.type==='meeting')return renderMeetingBlock(b,idx);
if(b.type==='table')return renderTableBlock(b,idx);
if(b.type==='divider')return `
`;
if(b.type==='image')return ``;
@@ -282,6 +283,235 @@
return ``;
}
+ // ═══════════ AI Meeting Note — widget de réunion (v4.12.0) ═══════════
+ // Bloc interactif « Notion-like » : consentement, transcription live (Web
+ // Speech API quand disponible), transcript horodaté + notes, puis résumé
+ // généré par le FlowDeck Agent.
+ function renderMeetingBlock(b, idx){
+ var actions=idx>=0?`+
`:'';
+ return `
+
+ ${actions}
+
Préparation de la réunion…
+
`;
+ }
+
+ function _fmFind(bid){var e=window.E;if(e&&e.blocks){for(var i=0;i${_fmEsc(st)} `:'';
+ var head=`
+ 🎙️
+
+ 📅 ${_fmEsc(m.date)}
+ ${pill}
+
`;
+
+ var body='';
+ if(!m.started){
+ body+=`
+
1 · Consentement des participants
+
🔘 Obtenir le consentement moi-même
+
🔘 Lire automatiquement un message audio
+
${audioOk?'Notion AI est prêt : la réunion sera transcrite en direct. Autorisez le micro lorsque le navigateur le demande.':'Ce navigateur ne supporte pas la transcription audio : vous pourrez saisir ou coller la transcription manuellement.'}
+
${audioOk?'🎙 Start transcribing':'Ouvrir la boîte de réunion (sans audio)'}
+
`;
+ }else{
+ var tabs=`
+ Notes
+ Transcript
+ Summary
+
`;
+ var ctrl='';
+ if(m.status==='running'){
+ ctrl=`•••|ı|ı|ı|ı•••
+
+ ⏸ Pause
+ ⏹ Stop
+
`;
+ }else if(m.status==='paused'){
+ ctrl=`
+ ▶ Reprendre
+ ⏹ Stop
+
`;
+ }else if(m.status==='manual'){
+ ctrl=`⏹ Terminer la réunion
`;
+ }else if(m.status==='done'){
+ ctrl=`🔄 Nouvelle transcription
`;
+ }
+ var panel='';
+ if(m.lastTab==='notes'){
+ panel=``;
+ }else if(m.lastTab==='transcript'){
+ var lines=(m.lines||[]).map(function(l){return `${_fmEsc(l.t||'')} ${_fmEsc(l.who||'Vous')} ${_fmEsc(l.text)}
`;}).join('');
+ if(m.status==='running'){lines+=`· Vous ${_fmEsc(m.interim||'')}${(m.interim||'')?'':'…'}
`;}
+ var manual=m.status!=='running'?`Transcript saisi ou collé (si aucune capture audio)
`:'';
+ panel=`${lines||'
La transcription apparaîtra ici en direct. Enregistrez la réunion pour générer un transcript.
'}
${manual}
`;
+ }else{
+ var summaryHtml='';
+ if(m.summaryLoading){
+ summaryHtml=`Génération du résumé…
`;
+ }else if(m.summary){
+ summaryHtml=`
+ 📋 Copier le résumé
`;
+ }else if(m.summaryError){
+ summaryHtml=`${_fmEsc(m.summaryError)}
+ Réessayer `;
+ }else{
+ var has=!!(_fmTranscriptText(b).trim());
+ if(m.status==='done'&&has){
+ summaryHtml=`✨ Générer le résumé
+ L'agent analyse le transcript et produit un aperçu structuré (sujets, décisions, actions).
`;
+ }else{
+ summaryHtml=`Arrêtez l'enregistrement (ou ajoutez un transcript) puis générez le résumé ici.
`;
+ }
+ }
+ panel=summaryHtml;
+ }
+ body=`${ctrl}${tabs}${panel}
`;
+ }
+ return `${head}${body}
`;
+ },
+ onInput(bid,el){
+ var b=_fmEnsure(bid);if(!b||!el)return;var m=b.meeting;var k=el.getAttribute&&el.getAttribute('data-fm');
+ if(!k)return;
+ if(k==='title'){m.title=el.value||'';var tEl=document.getElementById('_titleEl');if(tEl&&!((tEl.textContent||'').trim())){tEl.textContent=m.title;if(window.E)window.E.pageTitle=m.title;}}
+ else if(k==='notes'){m.notes=el.value;}
+ else if(k==='manual'){m.manualTranscript=el.value;}
+ else if(k==='summary'){m.summary=el.value;}
+ else if(k==='consent'){m.consent=el.value;}
+ else return;
+ _fmSave();
+ },
+ act(bid,act,el){
+ var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var self=this;
+ if(act==='start'){
+ if(m.status==='done'||m.status==='idle'||m.status==='paused'){
+ if(m.status==='done'){m.lines=[];m.summary='';m.summaryError='';m.manualTranscript='';}
+ m.started=true;m.lastTab='transcript';
+ if(!self._audio()){m.status='manual';m.interim='';_fmSave();self.paint(bid);return;}
+ if(m.consent==='auto'&&window.speechSynthesis){
+ var u=new SpeechSynthesisUtterance('Cette réunion est enregistrée et transcrite par FlowDeck. Merci de votre attention.');
+ try{window.speechSynthesis.speak(u);}catch(e){}
+ }
+ setTimeout(function(){self._startRec(bid);},m.consent==='auto'?1300:0);
+ }
+ }else if(act==='pause'){
+ this._stopRec(bid,false);m.status='paused';m.interim='';_fmSave();this.paint(bid);
+ }else if(act==='stop'){
+ this._stopRec(bid,true);m.status='done';m.lastTab='transcript';m.interim='';_fmSave();this.paint(bid);
+ }else if(act==='new'){
+ m.lines=[];m.summary='';m.summaryError='';m.status='idle';m.started=false;m.lastTab='transcript';_fmSave();this.paint(bid);
+ }else if(act==='tab'){
+ m.lastTab=(el&&el.getAttribute&&el.getAttribute('data-tab'))||'transcript';_fmSave();this.paint(bid);
+ }else if(act==='generate'){
+ this._generate(bid);
+ }else if(act==='copy'){
+ var txt=(m.summary||'').trim();
+ if(!txt)return;
+ if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).catch(function(){});}
+ }
+ },
+ _startRec(bid){
+ var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var s=this._rt[bid]||(this._rt[bid]={});
+ var SR=window.SpeechRecognition||window.webkitSpeechRecognition;
+ if(!SR){m.status='manual';_fmSave();this.paint(bid);return;}
+ if(s.rec){try{s.rec.stop();}catch(e){}}
+ var rec=new SR();s.rec=rec;s.offset=0;s.stopped=false;
+ rec.lang='fr-FR';rec.continuous=true;rec.interimResults=true;rec.maxAlternatives=1;
+ var self=this;
+ rec.onresult=function(ev){
+ var final='',interim='';
+ for(var i=(s.offset||0);i${_fmEsc(l.t||'')} ${_fmEsc(l.who||'Vous')} ${_fmEsc(l.text)} `;}).join('');return lines||'La transcription apparaîtra ici en direct…
';},
+ _generate(bid){
+ var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;
+ var content=_fmTranscriptText(b);
+ if(!content.trim()){m.summaryError='Aucun contenu de réunion à résumer.';_fmSave();this.paint(bid);return;}
+ m.summaryLoading=true;m.summaryError='';_fmSave();this.paint(bid);
+ var self=this;
+ var prompt='Rédige le résumé structuré de cette réunion (format AI Meeting Note) : une ligne d\'en-tête avec la date du '+_fmEsc(m.date)+', puis les sections « Résumé » (Overview), « Décisions » et « Action items » avec des puces concrètes. Si le contenu est trop court ou fragmentaire, indique « No meaningful content could be extracted ». Ne précède pas la réponse de commentaires.';
+ if(!(window.fdAgent&&window.fdAgent.generate)){
+ m.summaryLoading=false;m.summaryError='Agent IA indisponible — configurez-le dans Paramètres → Agent & IA.';_fmSave();this.paint(bid);return;
+ }
+ window.fdAgent.generate(prompt, content).then(function(text){
+ m.summary=(text||'').trim();m.summaryLoading=false;m.summaryError='';_fmSave();self.paint(bid);
+ }).catch(function(err){
+ m.summaryLoading=false;m.summaryError=(err&&err.message)?err.message:'Échec de génération du résumé';_fmSave();self.paint(bid);
+ });
+ }
+ };
+ window.FDMeeting=FDMeeting;
+
window.E=null;
function editorState(){
return {
@@ -377,8 +607,10 @@
var self=this;
ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');});
ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});});
- // Initialize inline database blocks (scripts in innerHTML don't execute)
- setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
+ // Initialize inline database blocks (scripts in innerHTML don't execute)
+ setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
+ // Initialize AI Meeting Note widgets (block type 'meeting')
+ setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/ ]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
},
@@ -401,7 +633,42 @@
this.aiRun('Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.');
},
meetingNote(){
- this.aiRun('Crée le squelette d\'une « AI meeting note » FlowDeck : une ligne d\'en-tête avec la date et les participants, puis les sections « Résumé », « Décisions » et « Action items » avec des exemples réalistes. Sépare chaque bloc/paragraphe par une ligne vide.');
+ // v4.12.0: crée une vraie « boîte de réunion » (bloc 'meeting') au lieu
+ // d'insérer du texte. La page est renommée « Meeting @date » si elle
+ // est vide, puis le widget est ajouté (ou remplace le bloc vide initial).
+ var self=this;
+ this.gsMoreOpen=false;
+ var today=new Date();
+ var iso=today.toISOString().slice(0,10);
+ var title='Meeting @'+iso;
+ var tEl=document.getElementById('_titleEl');
+ var curTitle=tEl?(tEl.textContent||'').trim():'';
+ var blank=!curTitle||curTitle==='Untitled'||curTitle==='New page';
+ if(blank){
+ this.pageTitle=title;
+ if(tEl)tEl.textContent=title;
+ }
+ var mb=this.mkB('meeting','');
+ mb.meeting={
+ title:title,
+ date:iso,
+ consent:'self',
+ started:false,
+ status:'idle',
+ lines:[],
+ interim:'',
+ notes:'',
+ summary:'',
+ summaryError:'',
+ lastTab:'transcript'
+ };
+ this.sync();
+ var emptyFirst=this.blocks.length===1 && this.blocks[0].type==='paragraph' && !(this.blocks[0].content||'').trim();
+ if(emptyFirst){ this.blocks=[mb]; } else { this.blocks.push(mb); }
+ this.dirty=true; this.autoSave(); this.render();
+ this.showToast('AI Meeting Note : bloc de réunion ajouté. Cliquez sur « Start transcribing ».');
+ var bid=mb.id;
+ setTimeout(function(){ var el=document.querySelector('[data-meeting-bid="'+bid+'"]'); if(el&&el.scrollIntoView)el.scrollIntoView({behavior:'smooth',block:'center'}); if(el&&window.FDMeeting)window.FDMeeting.focus(bid); },120);
},
aiRun(prompt){
const self=this;
@@ -828,7 +1095,7 @@
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
this.saving=true;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
- fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
+ fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
if(cb)cb();
diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html
index b142789..0ed6f7a 100644
--- a/app/templates/agent_panel.html
+++ b/app/templates/agent_panel.html
@@ -163,6 +163,54 @@
self.input = t;
setTimeout(function(){ self.send(); }, 60);
};
+ // Headless generation (no panel): used by the AI Meeting Note block to
+ // build its Summary. Returns a Promise of the final assistant text.
+ window.fdAgent.generate = function(message, context){
+ message = (message || '').trim();
+ if(!message) return Promise.resolve('');
+ var payload = {message: message};
+ if(context && context.trim()) payload.context = String(context).slice(0, 20000);
+ return fetch('/api/agent/conversations', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({title:'AI Meeting Note'})
+ }).then(function(r){ return r.json(); }).then(function(conv){
+ var cid = conv && conv.id;
+ if(!cid) throw new Error('Impossible de créer une conversation agent');
+ return fetch('/api/agent/conversations/' + cid + '/run', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify(payload)
+ }).then(function(resp){
+ if(!resp.ok){
+ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP ' + resp.status)); });
+ }
+ var reader = resp.body.getReader();
+ var dec = new TextDecoder();
+ var buf = '', finalText = '', lastError = '';
+ function pump(){
+ return reader.read().then(function(res){
+ if(res.done){
+ if(lastError) throw new Error(lastError);
+ return finalText;
+ }
+ buf += dec.decode(res.value, {stream:true});
+ var parts = buf.split('\n\n');
+ buf = parts.pop();
+ for(var i=0;iNew password
-
+
+ {{ fd_icon('eye-off',14) }}
+ {{ fd_icon('eye',14) }}
+
@@ -578,7 +581,10 @@
Clé API
-
+
+ {{ fd_icon('eye-off',14) }}
+ {{ fd_icon('eye',14) }}
+
@@ -996,7 +1004,9 @@ document.addEventListener('alpine:init', function() {
this.llmProviders.forEach(p => {
var k = keys.find(x => x.provider === p.id);
var f = this.keyForm(p.id);
- f.api_base = (k && k.api_base) || '';
+ // Pré-remplit l'URL avec celle réellement utilisée par l'application
+ // (base par défaut du fournisseur) tant qu'aucune URL personnalisée n'est stockée.
+ f.api_base = (k && k.api_base) || p.base_url || '';
f.model = (k && k.default_model) || (p.default_model) || '';
f.models = (k && k.models) || [];
f.has_key = !!(k && k.has_key);
@@ -1122,7 +1132,11 @@ document.addEventListener('alpine:init', function() {
this.llmProviders = d.providers || [];
this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
this.llmHasKey = !!d.has_api_key;
- this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''};
+ var defp = this.providerById(d.provider || 'offline');
+ // Affiche l'URL réellement utilisée par l'application (base par défaut
+ // du fournisseur) quand aucune URL personnalisée n'est encore stockée.
+ this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '',
+ api_base: d.api_base || (defp && defp.base_url) || ''};
this.applyLlmModels();
this.syncLlmStatus();
} catch(e) {}
@@ -1137,7 +1151,11 @@ document.addEventListener('alpine:init', function() {
onLlmProviderChange() {
this.applyLlmModels();
var cur = this.providerById(this.llmForm.provider);
- if (cur) this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
+ if (cur) {
+ this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
+ // Affiche l'URL utilisée par l'application pour ce fournisseur.
+ this.llmForm.api_base = cur.base_url || '';
+ }
},
async saveLlmConfig() {
this.llmSaving = true;
diff --git a/static/css/app.css b/static/css/app.css
index 759d661..094dc3c 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -2966,6 +2966,74 @@ button.breadcrumb-current { max-width: 260px; }
.gs-more-item:hover { background: var(--bg-hover); }
.gs-more-sep { height: 1px; background: var(--border); margin: 4px 0; }
+/* ═══════════ AI Meeting Note — widget réunion (v4.12.0) ═══════════ */
+.block-meeting { cursor: default; }
+.fm {
+ background: var(--bg-secondary, #1f1f1f); border: 1px solid var(--border, #333);
+ border-radius: 12px; padding: 12px 14px; font-size: 13px;
+ color: var(--text-primary, #eee); display: flex; flex-direction: column; gap: 10px;
+}
+.fm-loading { color: var(--text-dim); font-size: 13px; padding: 8px 2px; }
+.fm-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.fm-ico { font-size: 20px; }
+.fm-title {
+ flex: 1; min-width: 160px; background: transparent; border: 1px solid transparent;
+ color: var(--text-primary); font-size: 14px; font-weight: 600; outline: none;
+ border-radius: 6px; padding: 3px 6px; font-family: inherit;
+}
+.fm-title:focus { border-color: var(--accent); background: var(--bg-tertiary); }
+.fm-date { font-size: 12px; color: var(--text-dim); white-space: nowrap; }
+.fm-pill {
+ font-size: 11px; padding: 3px 10px; border-radius: 99px;
+ background: var(--bg-tertiary); color: var(--text-secondary); white-space: nowrap;
+}
+.fm-pill-ok { color: #2ea043; }
+.fm-step-title {
+ font-size: 12px; font-weight: 600; color: var(--text-dim);
+ text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px;
+}
+.fm-step-desc { font-size: 12px; color: var(--text-dim); line-height: 1.45; }
+.fm-radio {
+ display: flex; gap: 8px; align-items: center; font-size: 13px;
+ color: var(--text-primary); padding: 4px 2px; cursor: pointer;
+}
+.fm-radio input { accent-color: var(--accent); }
+.fm-btn {
+ background: var(--bg-tertiary); border: 1px solid var(--border);
+ color: var(--text-primary); border-radius: 8px; padding: 7px 14px;
+ font-size: 13px; cursor: pointer; font-family: inherit;
+}
+.fm-btn:hover { background: var(--bg-hover); }
+.fm-btn-primary { background: var(--accent, #2383E2); border-color: transparent; color: #fff; font-weight: 600; }
+.fm-btn-primary:hover { background: #2C8CEB; }
+.fm-btn:disabled { opacity: .55; cursor: not-allowed; }
+.fm-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.fm-wave {
+ font-family: monospace; letter-spacing: 2px; color: var(--accent);
+ font-size: 14px; padding: 2px 0;
+}
+.fm-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-top: 2px; }
+.fm-tab {
+ background: none; border: none; color: var(--text-dim); font-size: 12.5px;
+ cursor: pointer; padding: 6px 12px; border-radius: 7px; font-weight: 500;
+}
+.fm-tab.active { background: var(--bg-tertiary); color: var(--text-primary); }
+.fm-empty { color: var(--text-dim); font-size: 12.5px; padding: 8px 0; }
+.fm-error { color: #e5484d; font-size: 12.5px; padding: 8px 0; }
+.fm-line { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; color: var(--text-primary); }
+.fm-line .fm-ts { color: var(--text-dim); font-family: monospace; font-size: 11px; flex-shrink: 0; padding-top: 2px; }
+.fm-line .fm-who { color: var(--accent); font-weight: 600; flex-shrink: 0; }
+.fm-interim { opacity: .55; font-style: italic; }
+.fm-area {
+ width: 100%; box-sizing: border-box; background: var(--bg-tertiary);
+ border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary);
+ font-size: 13px; padding: 8px 10px; outline: none; font-family: inherit; resize: vertical;
+}
+.fm-area:focus { border-color: var(--accent); }
+.fm-manual { margin-top: 8px; }
+.fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
+.fm-transcript { max-height: 40vh; overflow-y: auto; }
+
/* ═══════════ Page Editor — Top Bar ═══════════ */
.page-topbar {
display: flex; align-items: center; justify-content: flex-end;