feat(taff): T3+T4 — JSON 24 plateformes scorées + page trouver-du-taf complète

- public/data/plateformes-taff.json : 24 plateformes (16 B2C + 8 AO),
  scoring 5 axes, tags globaux, descriptions IA 250 mots
- components/PlatformeTaffCard.vue : carte plateforme avec scoring axes
  et tag global coloré
- pages/trouver-du-taf.vue : page complète avec filtres (tag/secteur/search),
  onglets B2C / AO publics, grille responsive, modal fiche détaillée
- app.vue : onglet "Trouver du taf" ajouté dans la nav desktop

Distribution scoring : 7  recommandés / 14 ⚠️ sous réserve / 3  à éviter
(flag_validation_jules: true sur les 3  — validation Jules avant publication)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules Neny
2026-05-06 23:15:03 +02:00
parent 3b2fce335e
commit 95e1d1df20
4 changed files with 1427 additions and 41 deletions

View File

@@ -1,56 +1,472 @@
<template>
<div class="trouver-du-taf-page">
<!-- Squelette V1 - sera étoffé par T4 (front Nuxt cascade TAFF) -->
<section class="intro">
<h1>Trouver du taf en archi</h1>
<p class="intro-text">
Annuaire critique des plateformes de mise en relation archi - particulier.
Évaluations sur 5 axes : rémunération, transparence, pratiques pro, écologie, qualité du matching.
</p>
<p class="intro-disclaimer">
Page en construction. Données à venir : T2 scoring 5 axes en cours après livraison T1.
</p>
</section>
<div class="taff-page" style="background: var(--nav-bg); min-height: 100%;">
<!-- Intro -->
<div class="taff-header">
<div class="taff-header-inner">
<h1 class="taff-title">Trouver du taf en archi</h1>
<p class="taff-subtitle">
Annuaire critique des plateformes de mise en relation archi particulier.
Évaluées sur 5 axes éthiques rémunération, transparence, pratiques pro, écologie, qualité du matching.
Cible : archi freelance indépendant en France.
</p>
<div class="taff-stats">
<span class="taff-stat" style="color: #3d5534;">
<span class="taff-stat-dot" style="background: #5a7a4a;"></span>
{{ stats.recommande }} Recommandé{{ stats.recommande > 1 ? 's' : '' }} AEP
</span>
<span class="taff-stat" style="color: #7a5f2a;">
<span class="taff-stat-dot" style="background: #c4a472;"></span>
{{ stats.sous_reserve }} Sous réserve
</span>
<span class="taff-stat" style="color: #7a3322;">
<span class="taff-stat-dot" style="background: #a85d3e;"></span>
{{ stats.a_eviter }} À éviter
</span>
</div>
</div>
</div>
<!-- Filtres -->
<div class="taff-filters-bar">
<div class="taff-filters-inner">
<!-- Onglets B2C / AO publics -->
<div class="taff-tabs">
<button
type="button"
class="taff-tab"
:class="{ 'taff-tab--active': activeTab === 'b2c' }"
@click="activeTab = 'b2c'; resetFilters()"
>
Plateformes B2C
<span class="taff-tab-count">{{ b2cCount }}</span>
</button>
<button
type="button"
class="taff-tab"
:class="{ 'taff-tab--active': activeTab === 'ao' }"
@click="activeTab = 'ao'; resetFilters()"
>
Appels d'offres publics
<span class="taff-tab-count">{{ aoCount }}</span>
</button>
</div>
<!-- Filtres tag global -->
<div class="taff-filter-group">
<button
v-for="t in TAG_OPTIONS"
:key="t.value"
type="button"
class="taff-filter-btn"
:class="{ 'taff-filter-btn--active': filterTag === t.value }"
:style="filterTag === t.value
? `background: ${t.bg}; color: ${t.text}; border-color: ${t.accent};`
: ''"
@click="filterTag = filterTag === t.value ? '' : t.value"
>
{{ t.emoji }} {{ t.label }}
</button>
</div>
<!-- Filtre secteur (B2C uniquement) -->
<div v-if="activeTab === 'b2c'" class="taff-filter-group">
<button
v-for="s in SECTEUR_OPTIONS"
:key="s.value"
type="button"
class="taff-filter-btn"
:class="{ 'taff-filter-btn--active': filterSecteur === s.value }"
@click="filterSecteur = filterSecteur === s.value ? '' : s.value"
>
{{ s.label }}
</button>
</div>
<!-- Recherche -->
<label class="taff-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher..."
class="taff-search-input"
autocomplete="off"
/>
<button v-if="search" type="button" @click.stop="search = ''" class="taff-search-clear" aria-label="Effacer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<!-- Compteur + reset -->
<div class="flex items-center gap-3 ml-auto">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasFilters"
type="button"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
@click="resetFilters"
>Effacer</button>
</div>
</div>
</div>
<!-- ── Grille ─────────────────────────────────────────────────── -->
<div class="taff-grid-wrap">
<div v-if="filtered.length === 0" class="taff-empty">
<p style="color: var(--nav-text-muted);">Aucune plateforme ne correspond à ces filtres.</p>
<button type="button" class="taff-reset-btn" @click="resetFilters">Réinitialiser les filtres</button>
</div>
<div v-else class="taff-grid">
<PlatformeTaffCard
v-for="p in filtered"
:key="p.id"
:plateforme="p"
@open="openModal"
/>
</div>
</div>
<!-- ── Note juridique ────────────────────────────────────────── -->
<div class="taff-disclaimer">
<p>
Évaluations basées sur des données publiques (CGV, Trustpilot, presse spécialisée) collectées en mai 2026.
AEP est un méta-annuaire critique, pas un opérateur. Les fiches « À éviter ❌ » sont validées manuellement avant publication.
</p>
</div>
<!-- ── Modal ─────────────────────────────────────────────────── -->
<Teleport to="body">
<Transition name="taff-backdrop">
<div
v-if="modalPlateforme"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="closeModal"
aria-hidden="true"
/>
</Transition>
<Transition name="taff-modal">
<div
v-if="modalPlateforme"
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="width: min(760px, 92vw); max-height: 90vh; background: var(--nav-bg); border-radius: 16px; box-shadow: 0 16px 64px rgba(26,34,56,0.28); overflow: hidden;"
role="dialog"
aria-modal="true"
:aria-label="modalPlateforme.nom"
tabindex="-1"
@keydown.esc="closeModal"
>
<!-- Header -->
<div class="flex items-center justify-between px-5 py-3 shrink-0" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm font-semibold shrink-0"
:style="`background: ${modalTagConfig.bg}; color: ${modalTagConfig.text};`"
>{{ modalTagConfig.emoji }} {{ modalTagConfig.label }}</span>
<span class="font-semibold text-base truncate" style="color: var(--nav-text);">{{ modalPlateforme.nom }}</span>
</div>
<div class="flex items-center gap-2 shrink-0 ml-3">
<a
:href="modalPlateforme.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Visiter
</a>
<button
@click="closeModal"
class="w-8 h-8 rounded-lg flex items-center justify-center hover:opacity-70 transition-opacity"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Body -->
<div class="overflow-y-auto flex-1 px-5 py-5 space-y-5">
<!-- Scoring axes -->
<div>
<div class="modal-label">Évaluation AEP — 5 axes</div>
<div class="modal-axes">
<div
v-for="axe in AXES"
:key="axe.id"
v-show="modalPlateforme.scoring[axe.id] !== null"
class="modal-axe"
:style="`background: ${axeScoreBg(modalPlateforme.scoring[axe.id] as string)};`"
>
<span class="text-xl leading-none">{{ axe.icon }}</span>
<div class="flex flex-col">
<span class="text-xs font-bold uppercase tracking-wider" style="color: var(--nav-text-muted);">{{ axe.label }}</span>
<span class="text-lg leading-none" :style="`color: ${axeScoreText(modalPlateforme.scoring[axe.id] as string)};`">
{{ modalPlateforme.scoring[axe.id] }}
</span>
</div>
</div>
</div>
<p class="mt-3 text-sm leading-relaxed italic rounded-lg px-3 py-2.5" style="color: var(--nav-text-muted); background: var(--nav-bg-alt);">
{{ modalPlateforme.scoring.justification_tag }}
</p>
</div>
<!-- Description -->
<div>
<div class="modal-label">Fiche détaillée</div>
<div class="space-y-3">
<div v-for="section in parsedDescription" :key="section.title">
<h5 class="text-sm font-bold mb-1" style="color: var(--nav-text);">{{ section.title }}</h5>
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">{{ section.body }}</p>
</div>
</div>
</div>
<!-- Infos pratiques -->
<div>
<div class="modal-label">Infos pratiques</div>
<div class="modal-meta-grid">
<div class="modal-meta-item">
<span class="modal-meta-key">Type</span>
<span class="modal-meta-val">{{ modalPlateforme.type === 'b2c-mise-en-relation' ? 'Plateforme B2C' : 'Appels d\'offres publics' }}</span>
</div>
<div class="modal-meta-item">
<span class="modal-meta-key">Coût d'entrée</span>
<span class="modal-meta-val">{{ coutLabel(modalPlateforme.cout_entree) }}</span>
</div>
<div class="modal-meta-item">
<span class="modal-meta-key">Zone</span>
<span class="modal-meta-val">France entière</span>
</div>
<div class="modal-meta-item">
<span class="modal-meta-key">Secteurs</span>
<span class="modal-meta-val">{{ modalPlateforme.secteurs_servis.map(s => SECTEUR_LABELS[s] ?? s).join(', ') }}</span>
</div>
</div>
</div>
<!-- Footer meta -->
<div class="flex items-center gap-2 text-xs flex-wrap pb-1" style="color: var(--nav-text-muted);">
<span>Fiche créée le {{ modalPlateforme.date_creation_fiche }}</span>
<span>·</span>
<span>{{ modalPlateforme.source_donnees.length }} source{{ modalPlateforme.source_donnees.length > 1 ? 's' : '' }}</span>
<span v-if="modalPlateforme.flag_validation_jules" class="font-semibold" style="color: #7a5f2a;">
· ⚠️ En attente de validation avant publication
</span>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Filtres : à brancher par T4 (FiltreSecteur, FiltreTag) -->
<!-- Liste plateformes : à brancher par T4 (FichePlateforme) -->
<!-- Chatbot d'aiguillage : à brancher par T6 (ChatbotTaff réutilise ChatbotSheet.vue) -->
</div>
</template>
<script setup lang="ts">
// Types disponibles : import type { PlateformeTaff, ScoringTaff, TagGlobal } from '~/types/plateforme-taff'
// Data attendue : public/data/plateformes-taff.json (livrée par T2 + T3 après T1)
import type { PlateformeTaff } from '~/types/plateforme-taff'
useHead({
title: 'Trouver du taf en archi - AEP',
title: 'Trouver du taf en archi AEP',
meta: [
{ name: 'description', content: "Annuaire critique des plateformes B2C archi - particulier. Évaluations éthiques sur 5 axes." }
{ name: 'description', content: "Annuaire critique des plateformes B2C archiparticulier. Évaluations éthiques sur 5 axes : rémunération, transparence, pratiques pro, écologie, matching." }
]
})
const { data } = await useAsyncData('plateformes-taff', () =>
$fetch<{ meta: any; plateformes: PlateformeTaff[] }>('/data/plateformes-taff.json')
)
const allPlateformes = computed(() => data.value?.plateformes ?? [])
const stats = computed(() => data.value?.meta?.repartition ?? { recommande: 0, sous_reserve: 0, a_eviter: 0 })
const b2cCount = computed(() => allPlateformes.value.filter(p => p.type === 'b2c-mise-en-relation').length)
const aoCount = computed(() => allPlateformes.value.filter(p => p.type === 'appel-offre-public').length)
// Filtres
const activeTab = ref<'b2c' | 'ao'>('b2c')
const filterTag = ref('')
const filterSecteur = ref('')
const search = ref('')
const hasFilters = computed(() => !!(filterTag.value || filterSecteur.value || search.value))
function resetFilters() {
filterTag.value = ''
filterSecteur.value = ''
search.value = ''
}
const filtered = computed(() => {
let list = allPlateformes.value.filter(p =>
activeTab.value === 'b2c'
? p.type === 'b2c-mise-en-relation'
: p.type === 'appel-offre-public'
)
if (filterTag.value)
list = list.filter(p => p.scoring.tag_global === filterTag.value)
if (filterSecteur.value)
list = list.filter(p => (p.secteurs_servis as string[]).includes(filterSecteur.value))
if (search.value) {
const q = search.value.toLowerCase()
list = list.filter(p =>
p.nom.toLowerCase().includes(q) ||
p.description_courte.toLowerCase().includes(q)
)
}
const ORDER: Record<string, number> = { 'recommande': 0, 'sous-reserve': 1, 'a-eviter': 2 }
return [...list].sort((a, b) => (ORDER[a.scoring.tag_global] ?? 9) - (ORDER[b.scoring.tag_global] ?? 9))
})
// Options filtres
const TAG_OPTIONS = [
{ value: 'recommande', emoji: '✅', label: 'Recommandé', bg: 'rgba(90,122,74,0.12)', text: '#3d5534', accent: '#5a7a4a' },
{ value: 'sous-reserve', emoji: '⚠️', label: 'Sous réserve', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a', accent: '#c4a472' },
{ value: 'a-eviter', emoji: '❌', label: 'À éviter', bg: 'rgba(168,93,62,0.12)', text: '#7a3322', accent: '#a85d3e' },
]
const SECTEUR_OPTIONS = [
{ value: 'renovation', label: 'Rénovation' },
{ value: 'construction-neuve', label: 'Neuf' },
{ value: 'architecture-interieure', label: 'Archi intérieure' },
{ value: 'mar-conseil', label: 'MAR / Conseil' },
{ value: 'urbanisme', label: 'Urbanisme' },
{ value: 'paysage', label: 'Paysage' },
{ value: 'transversal', label: 'Transversal' },
]
const SECTEUR_LABELS: Record<string, string> = {
'renovation': 'Rénovation', 'construction-neuve': 'Neuf',
'architecture-interieure': 'Archi intérieure', 'urbanisme': 'Urbanisme',
'paysage': 'Paysage', 'mar-conseil': 'MAR / Conseil', 'transversal': 'Transversal',
}
const COUT_LABELS: Record<string, string> = {
'gratuit': 'Gratuit', 'freemium': 'Freemium',
'abonnement': 'Abonnement', 'lead-paye': 'Lead payant', 'commission': 'Commission',
}
function coutLabel(c: string) { return COUT_LABELS[c] ?? c }
// Axes
const AXES = [
{ id: 'remuneration' as const, icon: '🪙', label: 'Rémunération' },
{ id: 'transparence' as const, icon: '🔍', label: 'Transparence' },
{ id: 'pratiques' as const, icon: '⚖️', label: 'Pratiques pro' },
{ id: 'ecologie' as const, icon: '🌿', label: 'Écologie' },
{ id: 'matching' as const, icon: '🎯', label: 'Matching' },
]
function axeScoreBg(score: string) {
if (score === '✅') return 'rgba(90,122,74,0.1)'
if (score === '⚠️') return 'rgba(196,164,114,0.15)'
if (score === '❌') return 'rgba(168,93,62,0.1)'
return 'var(--nav-bg-alt)'
}
function axeScoreText(score: string) {
if (score === '✅') return '#3d5534'
if (score === '⚠️') return '#7a5f2a'
if (score === '❌') return '#7a3322'
return 'var(--nav-text-muted)'
}
// Modal
const modalPlateforme = ref<PlateformeTaff | null>(null)
function openModal(p: PlateformeTaff) { modalPlateforme.value = p }
function closeModal() { modalPlateforme.value = null }
const TAG_CONFIG: Record<string, { emoji: string; label: string; bg: string; text: string }> = {
'recommande': { emoji: '✅', label: 'Recommandé AEP', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
'sous-reserve': { emoji: '⚠️', label: 'Sous réserve', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a' },
'a-eviter': { emoji: '❌', label: 'À éviter', bg: 'rgba(168,93,62,0.12)', text: '#7a3322' },
}
const modalTagConfig = computed(() =>
modalPlateforme.value
? (TAG_CONFIG[modalPlateforme.value.scoring.tag_global] ?? TAG_CONFIG['sous-reserve'])
: TAG_CONFIG['sous-reserve']
)
// Parse description (format "## Titre\nContenu\n\n## Titre2\nContenu2")
const parsedDescription = computed(() => {
if (!modalPlateforme.value) return []
const raw = modalPlateforme.value.description
const sections: { title: string; body: string }[] = []
const parts = raw.split(/\n\n## /)
parts.forEach((part, i) => {
const text = i === 0 ? part.replace(/^## /, '') : part
const nl = text.indexOf('\n')
if (nl < 0) return
sections.push({ title: text.slice(0, nl).trim(), body: text.slice(nl + 1).trim() })
})
return sections
})
</script>
<style scoped>
.trouver-du-taf-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.intro h1 {
font-size: 2rem;
font-weight: 700;
color: var(--nav-text);
margin-bottom: 0.5rem;
}
.intro-text {
font-size: 1rem;
color: var(--nav-text);
line-height: 1.6;
margin-bottom: 1rem;
}
.intro-disclaimer {
font-size: 0.875rem;
color: var(--nav-text-muted);
font-style: italic;
}
.taff-page { max-width: 1280px; margin: 0 auto; padding-bottom: 3rem; }
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); }
.taff-header-inner { max-width: 680px; }
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 1rem; }
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; }
.taff-stat { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; font-weight: 600; }
.taff-stat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.taff-filters-bar { position: sticky; top: 0; z-index: 100; background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); padding: 0.75rem 1.5rem; box-shadow: 0 2px 8px rgba(26,34,56,0.06); }
.taff-filters-inner { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
.taff-tabs { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid var(--nav-bg-alt); flex-shrink: 0; }
.taff-tab { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.875rem; font-size: 0.8125rem; font-weight: 500; color: var(--nav-text-muted); background: var(--nav-bg); border: none; cursor: pointer; transition: background 0.15s; }
.taff-tab:first-child { border-right: 1px solid var(--nav-bg-alt); }
.taff-tab--active { background: var(--nav-primary-solid); color: var(--nav-text-on-primary); }
.taff-tab-count { font-size: 0.6875rem; opacity: 0.7; font-weight: 700; }
.taff-filter-group { display: flex; gap: 0.375rem; flex-wrap: wrap; }
.taff-filter-btn { padding: 0.3125rem 0.75rem; border-radius: 9999px; font-size: 0.8125rem; font-weight: 500; border: 1px solid var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text-muted); cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.taff-filter-btn:hover { background: var(--nav-bg-alt); }
.taff-filter-btn--active { font-weight: 600; }
.taff-search { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; border-radius: 8px; border: 1px solid var(--nav-bg-alt); background: var(--nav-bg); flex: 1; min-width: 160px; max-width: 240px; }
.taff-search-input { flex: 1; background: transparent; border: none; outline: none; font-size: 0.8125rem; color: var(--nav-text); min-width: 0; }
.taff-search-input::placeholder { color: var(--nav-text-muted); }
.taff-search-clear { color: var(--nav-text-muted); background: none; border: none; cursor: pointer; padding: 0; display: flex; }
.taff-grid-wrap { padding: 1.5rem; }
.taff-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.taff-empty { text-align: center; padding: 3rem; }
.taff-reset-btn { margin-top: 0.75rem; padding: 0.5rem 1.25rem; border-radius: 8px; background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; border: none; cursor: pointer; }
.taff-reset-btn:hover { opacity: 0.7; }
.taff-disclaimer { margin: 0 1.5rem; padding: 0.875rem 1.25rem; border-radius: 10px; font-size: 0.8125rem; line-height: 1.55; color: var(--nav-text-muted); background: var(--nav-bg-alt); }
/* Modal body helpers */
.modal-label { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nav-text-muted); margin-bottom: 0.75rem; }
.modal-axes { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.5rem; }
.modal-axe { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 8px; }
.modal-meta-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; }
.modal-meta-item { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.6rem 0.875rem; border-radius: 8px; background: var(--nav-bg-alt); }
.modal-meta-key { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; color: var(--nav-text-muted); }
.modal-meta-val { font-size: 0.875rem; font-weight: 500; color: var(--nav-text); }
/* Transitions */
.taff-backdrop-enter-active, .taff-backdrop-leave-active { transition: opacity 0.2s; }
.taff-backdrop-enter-from, .taff-backdrop-leave-to { opacity: 0; }
.taff-modal-enter-active, .taff-modal-leave-active { transition: opacity 0.2s, transform 0.2s; }
.taff-modal-enter-from, .taff-modal-leave-to { opacity: 0; transform: translate(-50%, calc(-50% + 12px)); }
</style>