Files
decision/frontend/app/pages/protocols/index.vue
Yvv 9a8f10efdf Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles
- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD
- /api/v1/groups : liste, création, suppression, membres (add/remove)
- groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember)
- decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini
  (affected_count calculé depuis le member_count du groupe)
- protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres
- lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé
- n8n channels : prévu sprint futur (texte libre → webhook appel à contribution)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:25:44 +02:00

1546 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Protocoles & Fonctionnement — Boîte à outils de vote.
*
* Liste les protocoles de vote avec SectionLayout,
* sidebar n8n workflow + simulateur de formules.
*/
const protocols = useProtocolsStore()
const auth = useAuthStore()
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const newProtocol = reactive({
name: '',
description: '',
vote_type: 'binary',
formula_config_id: '',
})
onMounted(async () => {
await Promise.all([
protocols.fetchProtocols(),
protocols.fetchFormulas(),
groupsStore.fetchAll(),
])
})
const voteTypeLabel = (voteType: string) => {
switch (voteType) {
case 'binary': return 'Binaire'
case 'nuanced': return 'Nuancé'
default: return voteType
}
}
const voteTypeOptions = [
{ label: 'Binaire (Pour/Contre)', value: 'binary' },
{ label: 'Nuancé (6 niveaux)', value: 'nuanced' },
]
const formulaOptions = computed(() => {
return protocols.formulas.map(f => ({
label: f.name,
value: f.id,
}))
})
/** Status pills. */
const statuses = computed(() => [
{
id: 'binary',
label: 'Binaire',
count: protocols.protocols.filter(p => p.vote_type === 'binary').length,
cssClass: 'status-vote',
},
{
id: 'nuanced',
label: 'Nuancé',
count: protocols.protocols.filter(p => p.vote_type === 'nuanced').length,
cssClass: 'status-prepa',
},
])
/** Filtered protocols. */
const filteredProtocols = computed(() => {
let list = [...protocols.protocols]
if (activeStatus.value) {
list = list.filter(p => p.vote_type === activeStatus.value)
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(p =>
p.name.toLowerCase().includes(q)
|| (p.description && p.description.toLowerCase().includes(q)),
)
}
return list
})
function openCreateModal() {
newProtocol.name = ''
newProtocol.description = ''
newProtocol.vote_type = 'binary'
newProtocol.formula_config_id = ''
showCreateModal.value = true
}
async function createProtocol() {
if (!newProtocol.name.trim() || !newProtocol.formula_config_id) return
creating.value = true
try {
await protocols.createProtocol({
name: newProtocol.name.trim(),
description: newProtocol.description.trim() || null,
vote_type: newProtocol.vote_type,
formula_config_id: newProtocol.formula_config_id,
})
showCreateModal.value = false
await protocols.fetchProtocols()
}
finally {
creating.value = false
}
}
/** Operational protocols (workflow templates). */
interface WorkflowStep {
label: string
actor: string
icon: string
type: string
}
interface LinkedRef {
label: string
icon: string
to: string
kind: 'document' | 'decision'
}
interface OperationalProtocol {
slug: string
name: string
description: string
category: string
icon: string
instancesLabel: string
linkedRefs: LinkedRef[]
steps: WorkflowStep[]
}
const operationalProtocols: OperationalProtocol[] = [
{
slug: 'embarquement-forgeron',
name: 'Embarquement Forgeron',
description: 'Processus complet d\'intégration d\'un nouveau forgeron dans le réseau Duniter. Parcours en 5 jalons de la candidature à la mise en ligne du nœud validateur.',
category: 'onboarding',
icon: 'i-lucide-hammer',
instancesLabel: '~10-50 / an',
linkedRefs: [
{ label: 'Acte d\'engagement forgeron', icon: 'i-lucide-book-open', to: '/documents/engagement-forgeron', kind: 'document' },
],
steps: [
{ label: 'Candidature', actor: 'Aspirant forgeron', icon: 'i-lucide-user-plus', type: 'checklist' },
{ label: 'Nœud miroir', actor: 'Candidat', icon: 'i-lucide-server', type: 'on_chain' },
{ label: 'Évaluation technique', actor: 'Certificateur', icon: 'i-lucide-clipboard-check', type: 'checklist' },
{ label: 'Certification Smith (×3)', actor: 'Certificateurs', icon: 'i-lucide-stamp', type: 'certification' },
{ label: 'Go online', actor: 'Candidat', icon: 'i-lucide-wifi', type: 'on_chain' },
],
},
{
slug: 'soumission-runtime-upgrade',
name: 'Soumission Runtime Upgrade',
description: 'Protocole de soumission d\'une mise à jour du runtime Duniter V2 on-chain. Chaque upgrade suit un parcours strict en 5 étapes, de la qualification technique au suivi post-déploiement.',
category: 'on-chain',
icon: 'i-lucide-cpu',
instancesLabel: '~2-6 / an',
linkedRefs: [
{ label: 'Décision Runtime Upgrade', icon: 'i-lucide-scale', to: '/decisions', kind: 'decision' },
],
steps: [
{ label: 'Qualification', actor: 'Proposant', icon: 'i-lucide-file-check', type: 'checklist' },
{ label: 'Revue technique', actor: 'Comité technique', icon: 'i-lucide-search', type: 'checklist' },
{ label: 'Vote communautaire', actor: 'Communauté WoT', icon: 'i-lucide-vote', type: 'on_chain' },
{ label: 'Exécution on-chain', actor: 'Proposant', icon: 'i-lucide-zap', type: 'on_chain' },
{ label: 'Suivi post-upgrade', actor: 'Forgerons', icon: 'i-lucide-activity', type: 'checklist' },
],
},
]
// ── Groups ─────────────────────────────────────────────────────────────────
const groupsStore = useGroupsStore()
const showGroupModal = ref(false)
const newGroupName = ref('')
const newGroupDesc = ref('')
const creatingGroup = ref(false)
const expandedGroupId = ref<string | null>(null)
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
const loadingGroupDetail = ref(false)
const newMemberName = ref('')
const addingMember = ref(false)
async function openGroupModal() {
newGroupName.value = ''
newGroupDesc.value = ''
showGroupModal.value = true
}
async function createGroup() {
if (!newGroupName.value.trim()) return
creatingGroup.value = true
try {
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
showGroupModal.value = false
} finally {
creatingGroup.value = false
}
}
async function toggleGroupDetail(groupId: string) {
if (expandedGroupId.value === groupId) {
expandedGroupId.value = null
expandedGroupDetail.value = null
return
}
expandedGroupId.value = groupId
loadingGroupDetail.value = true
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
loadingGroupDetail.value = false
}
async function addMember(groupId: string) {
if (!newMemberName.value.trim()) return
addingMember.value = true
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
if (member && expandedGroupDetail.value) {
expandedGroupDetail.value.members.push(member)
newMemberName.value = ''
}
addingMember.value = false
}
async function removeMember(groupId: string, memberId: string) {
const ok = await groupsStore.removeMember(groupId, memberId)
if (ok && expandedGroupDetail.value) {
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
}
}
async function deleteGroup(groupId: string) {
await groupsStore.remove(groupId)
if (expandedGroupId.value === groupId) {
expandedGroupId.value = null
expandedGroupDetail.value = null
}
}
/** n8n workflow demo items. */
const n8nWorkflows = [
{
name: 'Vote -> Notification',
description: 'Notifie les membres lorsqu\'un nouveau vote démarre ou se termine.',
icon: 'i-lucide-bell',
status: 'actif',
},
{
name: 'Seuil atteint -> Sanctuaire',
description: 'Archive automatiquement le document valide sur IPFS quand le seuil est atteint.',
icon: 'i-lucide-archive',
status: 'actif',
},
{
name: 'Décision → Étape suivante',
description: 'Avance automatiquement une décision à l\'étape suivante après validation.',
icon: 'i-lucide-git-branch',
status: 'demo',
},
{
name: 'Mandat expiré → Alerte',
description: 'Envoie une alerte 7 jours avant l\'expiration d\'un mandat.',
icon: 'i-lucide-alarm-clock',
status: 'demo',
},
]
</script>
<template>
<SectionLayout
title="Protocoles & Fonctionnement"
subtitle="Boîte à outils de vote, formules de seuil, workflows automatisés"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search slot -->
<template #search>
<input
v-model="searchQuery"
type="text"
class="proto-search"
placeholder="Rechercher un protocole..."
/>
<button
v-if="auth.isAuthenticated"
class="proto-add-btn"
@click="openCreateModal"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span>
</button>
</template>
<!-- Loading -->
<template v-if="protocols.loading">
<div class="proto-loading">
<LoadingSkeleton v-for="i in 3" :key="i" :lines="4" card />
</div>
</template>
<!-- Error -->
<template v-else-if="protocols.error">
<div class="proto-error">
<UIcon name="i-lucide-alert-circle" class="text-lg" />
<span>{{ protocols.error }}</span>
</div>
</template>
<!-- Protocol list -->
<template v-else>
<div v-if="filteredProtocols.length === 0" class="proto-empty">
<UIcon name="i-lucide-settings" class="text-2xl" />
<p>Aucun protocole trouvé</p>
</div>
<div v-else class="proto-list">
<NuxtLink
v-for="protocol in filteredProtocols"
:key="protocol.id"
:to="`/protocols/${protocol.id}`"
class="proto-item"
>
<div class="proto-item__left">
<div class="proto-item__icon">
<UIcon
:name="protocol.vote_type === 'nuanced' ? 'i-lucide-bar-chart-3' : 'i-lucide-check-circle'"
class="text-lg"
/>
</div>
</div>
<div class="proto-item__body">
<div class="proto-item__head">
<h3 class="proto-item__name">{{ protocol.name }}</h3>
<span class="proto-item__type" :class="protocol.vote_type === 'nuanced' ? 'proto-item__type--nuanced' : ''">
{{ voteTypeLabel(protocol.vote_type) }}
</span>
</div>
<p v-if="protocol.description" class="proto-item__desc">
{{ protocol.description }}
</p>
<div class="proto-item__meta">
<span v-if="protocol.mode_params" class="proto-item__params">
{{ protocol.mode_params }}
</span>
<span class="proto-item__formula">
{{ protocol.formula_config.name }}
</span>
<span class="proto-item__detail">
{{ protocol.formula_config.duration_days }}j · {{ protocol.formula_config.majority_pct }}%
</span>
</div>
</div>
<div class="proto-item__arrow">
<UIcon name="i-lucide-chevron-right" class="text-sm" />
</div>
</NuxtLink>
</div>
<!-- Formulas table -->
<div class="proto-formulas">
<h3 class="proto-formulas__title">
<UIcon name="i-lucide-calculator" class="text-sm" />
Configurations de formule
<span class="proto-formulas__count">{{ protocols.formulas.length }}</span>
</h3>
<div class="proto-formulas__table-wrap">
<table class="proto-formulas__table">
<thead>
<tr>
<th>Nom</th>
<th>Durée</th>
<th>Majorité</th>
<th>B</th>
<th>G</th>
<th>Smith</th>
<th>TechComm</th>
</tr>
</thead>
<tbody>
<tr v-for="formula in protocols.formulas" :key="formula.id">
<td class="proto-formulas__name">{{ formula.name }}</td>
<td>{{ formula.duration_days }}j</td>
<td>{{ formula.majority_pct }}%</td>
<td>{{ formula.base_exponent }}</td>
<td>{{ formula.gradient_exponent }}</td>
<td>{{ formula.smith_exponent ?? '-' }}</td>
<td>{{ formula.techcomm_exponent ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- Groups -->
<div class="proto-groups">
<h3 class="proto-groups__title">
<UIcon name="i-lucide-users-round" class="text-sm" />
Groupes d'identités
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
<UIcon name="i-lucide-plus" class="text-sm" />
Nouveau groupe
</button>
</h3>
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
<UIcon name="i-lucide-users" class="text-lg" />
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
</div>
<div v-else class="proto-groups__list">
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
<div class="proto-groups__item-info">
<UIcon name="i-lucide-users" class="text-sm" />
<span class="proto-groups__item-name">{{ g.name }}</span>
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
</div>
<div class="proto-groups__item-actions">
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
<UIcon name="i-lucide-trash-2" class="text-xs" />
</button>
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
</div>
</div>
<Transition name="slide-down">
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
</div>
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
<UIcon name="i-lucide-user" class="text-xs" />
<span>{{ m.display_name }}</span>
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
<UIcon name="i-lucide-x" class="text-xs" />
</button>
</li>
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
Aucun membre
</li>
</ul>
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
<input
v-model="newMemberName"
type="text"
lang="fr"
spellcheck="true"
class="proto-groups__member-input"
placeholder="Nom ou adresse Duniter"
@keydown.enter="addMember(g.id)"
/>
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
Ajouter
</button>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Operational protocols (always visible, frontend-only data) -->
<div class="proto-ops">
<h3 class="proto-ops__title">
<UIcon name="i-lucide-git-branch" class="text-sm" />
Protocoles opérationnels
<span class="proto-ops__count">{{ operationalProtocols.length }}</span>
</h3>
<div
v-for="op in operationalProtocols"
:key="op.slug"
class="proto-ops__card"
>
<div class="proto-ops__card-head">
<div class="proto-ops__card-icon">
<UIcon :name="op.icon" class="text-lg" />
</div>
<div class="proto-ops__card-info">
<h4 class="proto-ops__card-name">{{ op.name }}</h4>
<p class="proto-ops__card-desc">{{ op.description }}</p>
<span class="proto-ops__card-meta">{{ op.instancesLabel }}</span>
</div>
</div>
<!-- Linked references -->
<div v-if="op.linkedRefs.length > 0" class="proto-ops__refs">
<NuxtLink
v-for="ref in op.linkedRefs"
:key="ref.to"
:to="ref.to"
class="proto-ops__ref"
:class="`proto-ops__ref--${ref.kind}`"
>
<UIcon :name="ref.icon" class="text-xs" />
<span>{{ ref.label }}</span>
<UIcon name="i-lucide-arrow-right" class="text-xs proto-ops__ref-arrow" />
</NuxtLink>
</div>
<!-- Step timeline -->
<div class="proto-ops__timeline">
<div
v-for="(step, idx) in op.steps"
:key="idx"
class="proto-ops__step"
>
<div class="proto-ops__step-dot" :class="`proto-ops__step-dot--${step.type}`">
<UIcon :name="step.icon" class="text-xs" />
</div>
<div class="proto-ops__step-body">
<span class="proto-ops__step-label">{{ step.label }}</span>
<span class="proto-ops__step-actor">{{ step.actor }}</span>
</div>
<div v-if="idx < op.steps.length - 1" class="proto-ops__step-line" />
</div>
</div>
</div>
</div>
<!-- Toolbox sidebar -->
<template #toolbox>
<!-- Workflow milestones -->
<ToolboxSection title="Jalons de protocole" icon="i-lucide-git-branch">
<WorkflowMilestones />
</ToolboxSection>
<!-- Simulateur -->
<ToolboxVignette
title="Simulateur de formules"
:bullets="['WoT, Smith, TechComm', 'Paramètres en temps réel', 'Visualise les seuils']"
:actions="[
{ label: 'Ouvrir', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
]"
/>
<!-- n8n Workflows -->
<ToolboxSection title="Automatisations" icon="i-lucide-workflow">
<div class="n8n-workflows">
<div
v-for="wf in n8nWorkflows"
:key="wf.name"
class="n8n-wf"
>
<div class="n8n-wf__icon">
<UIcon :name="wf.icon" class="text-xs" />
</div>
<div class="n8n-wf__body">
<div class="n8n-wf__head">
<span class="n8n-wf__name">{{ wf.name }}</span>
<span
class="n8n-wf__status"
:class="wf.status === 'actif' ? 'n8n-wf__status--active' : 'n8n-wf__status--demo'"
>
{{ wf.status }}
</span>
</div>
<p class="n8n-wf__desc">{{ wf.description }}</p>
</div>
</div>
</div>
</ToolboxSection>
<!-- Meta-gouvernance -->
<ToolboxVignette
title="Méta-gouvernance"
:bullets="['Les formules sont soumises au vote', 'Seuils modifiables collectivement', 'Transparence totale']"
:actions="[
{ label: 'Démarrer', icon: 'i-lucide-play', emit: 'meta', primary: true },
]"
/>
</template>
</SectionLayout>
<!-- Create protocol modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<div class="proto-modal">
<h3 class="proto-modal__title">Nouveau protocole de vote</h3>
<div class="proto-modal__fields">
<div class="proto-modal__field">
<label class="proto-modal__label">Nom du protocole</label>
<input
v-model="newProtocol.name"
type="text"
class="proto-modal__input"
placeholder="Ex: Vote standard G1"
/>
</div>
<div class="proto-modal__field">
<label class="proto-modal__label">Description</label>
<textarea
v-model="newProtocol.description"
class="proto-modal__textarea"
placeholder="Description du protocole..."
rows="2"
/>
</div>
<div class="proto-modal__field">
<label class="proto-modal__label">Type de vote</label>
<USelect
v-model="newProtocol.vote_type"
:items="voteTypeOptions"
value-key="value"
/>
</div>
<div class="proto-modal__field">
<label class="proto-modal__label">Configuration de formule</label>
<USelect
v-model="newProtocol.formula_config_id"
:items="formulaOptions"
placeholder="Sélectionnez une formule..."
value-key="value"
/>
</div>
</div>
<div class="proto-modal__actions">
<button class="proto-modal__cancel" @click="showCreateModal = false">
Annuler
</button>
<button
class="proto-modal__submit"
:disabled="!newProtocol.name.trim() || !newProtocol.formula_config_id || creating"
@click="createProtocol"
>
<UIcon v-if="creating" name="i-lucide-loader-2" class="animate-spin text-xs" />
<span>Créer</span>
</button>
</div>
</div>
</template>
</UModal>
<!-- Create group modal -->
<UModal v-model:open="showGroupModal">
<template #content>
<div class="proto-modal">
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
<div class="proto-modal__fields">
<div class="proto-modal__field">
<label class="proto-modal__label">Nom du groupe</label>
<input
v-model="newGroupName"
type="text"
lang="fr"
spellcheck="true"
class="proto-modal__input"
placeholder="Ex: Comité technique, Forgerons actifs…"
/>
</div>
<div class="proto-modal__field">
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
<textarea
v-model="newGroupDesc"
class="proto-modal__textarea"
lang="fr"
spellcheck="true"
placeholder="Rôle ou périmètre de ce groupe…"
rows="2"
/>
</div>
</div>
<div class="proto-modal__actions">
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
<button
class="proto-modal__submit"
:disabled="!newGroupName.trim() || creatingGroup"
@click="createGroup"
>
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
<UIcon v-else name="i-lucide-users-round" />
Créer le groupe
</button>
</div>
</div>
</template>
</UModal>
</template>
<style scoped>
/* --- Search --- */
.proto-search {
flex: 1;
min-width: 10rem;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
outline: none;
transition: box-shadow 0.15s ease;
}
.proto-search:focus {
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.proto-search::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.proto-add-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.proto-add-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* --- States --- */
.proto-loading { display: flex; flex-direction: column; gap: 0.75rem; }
.proto-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-error);
background: rgba(196, 43, 43, 0.08);
border-radius: 16px;
}
.proto-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 3rem;
text-align: center;
color: var(--mood-text-muted);
font-size: 1rem;
}
/* --- Protocol list --- */
.proto-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proto-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.proto-item {
gap: 1rem;
padding: 1.25rem;
}
}
.proto-item:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.proto-item:active {
transform: translateY(0);
}
.proto-item__left { flex-shrink: 0; }
.proto-item__icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
font-size: 1rem;
}
@media (min-width: 640px) {
.proto-item__icon {
width: 2.75rem;
height: 2.75rem;
font-size: 1.25rem;
}
}
.proto-item__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@media (min-width: 640px) {
.proto-item__body {
gap: 0.375rem;
}
}
.proto-item__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
@media (min-width: 640px) {
.proto-item__head {
gap: 0.625rem;
}
}
.proto-item__name {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0;
}
@media (min-width: 640px) {
.proto-item__name {
font-size: 1.0625rem;
}
}
.proto-item__type {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-status-vote-bg);
color: var(--mood-status-vote);
}
.proto-item__type--nuanced {
background: var(--mood-status-prepa-bg);
color: var(--mood-status-prepa);
}
.proto-item__desc {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.5;
margin: 0;
}
.proto-item__meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
}
@media (min-width: 640px) {
.proto-item__meta {
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.8125rem;
}
}
.proto-item__params {
font-family: ui-monospace, SFMono-Regular, monospace;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-accent-soft);
padding: 2px 8px;
border-radius: 20px;
font-size: 0.75rem;
}
.proto-item__formula {
font-weight: 600;
}
.proto-item__detail {
opacity: 0.7;
}
.proto-item__arrow {
flex-shrink: 0;
color: var(--mood-text-muted);
opacity: 0.3;
margin-top: 0.375rem;
transition: opacity 0.12s;
}
.proto-item:hover .proto-item__arrow {
opacity: 1;
color: var(--mood-accent);
}
/* --- Formulas --- */
.proto-formulas {
margin-top: 0.75rem;
}
@media (min-width: 640px) {
.proto-formulas {
margin-top: 1rem;
}
}
.proto-formulas__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0 0 0.5rem;
}
@media (min-width: 640px) {
.proto-formulas__title {
font-size: 1rem;
margin: 0 0 0.75rem;
}
}
.proto-formulas__count {
font-size: 0.6875rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
padding: 2px 8px;
border-radius: 20px;
}
.proto-formulas__table-wrap {
background: var(--mood-surface);
border-radius: 16px;
overflow-x: auto;
}
.proto-formulas__table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
}
.proto-formulas__table th {
text-align: left;
padding: 0.5rem 0.625rem;
font-weight: 700;
font-size: 0.6875rem;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
@media (min-width: 640px) {
.proto-formulas__table th {
padding: 0.75rem 1rem;
font-size: 0.75rem;
}
}
.proto-formulas__table td {
padding: 0.5rem 0.625rem;
color: var(--mood-text-muted);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
}
@media (min-width: 640px) {
.proto-formulas__table td {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
}
}
.proto-formulas__name {
font-weight: 700;
color: var(--mood-text) !important;
font-family: inherit !important;
}
.n8n-workflows {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.25rem;
}
.n8n-wf {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 10px;
transition: background 0.1s;
}
.n8n-wf:hover {
background: var(--mood-surface);
}
.n8n-wf__icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mood-surface);
color: var(--mood-tertiary, var(--mood-accent));
}
.n8n-wf__body {
min-width: 0;
flex: 1;
}
.n8n-wf__head {
display: flex;
align-items: center;
gap: 0.375rem;
}
.n8n-wf__name {
font-size: 0.75rem;
font-weight: 700;
color: var(--mood-text);
}
.n8n-wf__status {
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 20px;
}
.n8n-wf__status--active {
background: var(--mood-status-vigueur-bg);
color: var(--mood-success);
}
.n8n-wf__status--demo {
background: var(--mood-surface);
color: var(--mood-text-muted);
}
.n8n-wf__desc {
font-size: 0.6875rem;
color: var(--mood-text-muted);
line-height: 1.4;
margin: 0;
}
/* --- Operational protocols --- */
.proto-ops {
margin-top: 1.5rem;
}
.proto-ops__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0 0 0.75rem;
}
.proto-ops__count {
font-size: 0.6875rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
padding: 2px 8px;
border-radius: 20px;
}
.proto-ops__card {
background: var(--mood-surface);
border-radius: 16px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.proto-ops__card-head {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.proto-ops__card-icon {
width: 2.75rem;
height: 2.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.proto-ops__card-info {
flex: 1;
min-width: 0;
}
.proto-ops__card-name {
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
.proto-ops__card-desc {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.4;
margin: 0.125rem 0 0;
}
.proto-ops__card-meta {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-accent);
opacity: 0.7;
}
/* Linked references */
.proto-ops__refs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.proto-ops__ref {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 20px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.proto-ops__ref:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.proto-ops__ref--document {
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
color: var(--mood-accent);
}
.proto-ops__ref--decision {
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 12%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.proto-ops__ref-arrow {
opacity: 0.4;
transition: opacity 0.12s;
}
.proto-ops__ref:hover .proto-ops__ref-arrow {
opacity: 1;
}
/* Timeline */
.proto-ops__timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-left: 0.25rem;
}
.proto-ops__step {
display: flex;
align-items: center;
gap: 0.625rem;
position: relative;
padding: 0.375rem 0;
}
.proto-ops__step-dot {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
z-index: 1;
}
.proto-ops__step-dot--on_chain {
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
color: var(--mood-success);
}
.proto-ops__step-dot--checklist {
background: color-mix(in srgb, var(--mood-warning) 15%, transparent);
color: var(--mood-warning);
}
.proto-ops__step-dot--certification {
background: color-mix(in srgb, var(--mood-secondary) 15%, transparent);
color: var(--mood-secondary, var(--mood-accent));
}
.proto-ops__step-body {
display: flex;
flex-direction: column;
}
.proto-ops__step-label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text);
}
.proto-ops__step-actor {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.proto-ops__step-line {
position: absolute;
left: calc(0.875rem - 1px);
top: calc(0.375rem + 1.75rem);
width: 2px;
height: calc(100% - 1.75rem + 0.375rem);
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
}
/* --- Modal --- */
.proto-modal {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
@media (min-width: 640px) {
.proto-modal {
padding: 2rem;
gap: 1.5rem;
}
}
.proto-modal__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
margin: 0;
}
@media (min-width: 640px) {
.proto-modal__title {
font-size: 1.375rem;
}
}
.proto-modal__fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
.proto-modal__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.proto-modal__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.proto-modal__input,
.proto-modal__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.9375rem;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
outline: none;
transition: box-shadow 0.15s ease;
font-family: inherit;
resize: vertical;
}
.proto-modal__input:focus,
.proto-modal__textarea:focus {
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.proto-modal__actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.proto-modal__cancel {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
border-radius: 12px;
cursor: pointer;
}
.proto-modal__cancel:hover { color: var(--mood-text); background: var(--mood-accent-soft); }
.proto-modal__submit {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.proto-modal__submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
/* --- Groups --- */
.proto-groups {
margin-top: 2rem;
}
.proto-groups__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin-bottom: 1rem;
}
.proto-groups__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.375rem;
height: 1.375rem;
padding: 0 0.375rem;
font-size: 0.75rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
border-radius: 20px;
}
.proto-groups__add-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border-radius: 16px;
padding: 0.25rem 0.75rem;
cursor: pointer;
transition: transform 0.1s ease;
}
.proto-groups__add-btn:hover { transform: translateY(-1px); }
.proto-groups__empty {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 1rem 1.25rem;
color: var(--mood-muted);
background: var(--mood-surface);
border-radius: 14px;
font-size: 0.875rem;
}
.proto-groups__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.proto-groups__item {
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
}
.proto-groups__item-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.12s ease;
}
.proto-groups__item-head:hover { background: var(--mood-hover); }
.proto-groups__item-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-text);
}
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
.proto-groups__item-count {
font-size: 0.75rem;
color: var(--mood-muted);
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.125rem 0.5rem;
}
.proto-groups__item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-muted);
}
.proto-groups__delete-btn {
color: var(--mood-danger, #e53e3e);
opacity: 0.5;
cursor: pointer;
transition: opacity 0.1s;
}
.proto-groups__delete-btn:hover { opacity: 1; }
.proto-groups__detail {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
}
.proto-groups__detail-desc {
font-size: 0.875rem;
color: var(--mood-muted);
margin-bottom: 0.75rem;
}
.proto-groups__members-loading {
display: flex;
justify-content: center;
padding: 0.5rem;
color: var(--mood-muted);
}
.proto-groups__members {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.proto-groups__member {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--mood-text);
padding: 0.375rem 0.5rem;
background: var(--mood-bg);
border-radius: 8px;
}
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
.proto-groups__member-remove {
margin-left: auto;
color: var(--mood-danger, #e53e3e);
opacity: 0.4;
cursor: pointer;
transition: opacity 0.1s;
}
.proto-groups__member-remove:hover { opacity: 1; }
.proto-groups__add-member {
display: flex;
gap: 0.5rem;
align-items: center;
}
.proto-groups__member-input {
flex: 1;
padding: 0.4375rem 0.75rem;
font-size: 0.875rem;
color: var(--mood-text);
background: var(--mood-bg);
border-radius: 10px;
outline: none;
}
.proto-groups__member-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 14px;
cursor: pointer;
transition: transform 0.1s ease;
white-space: nowrap;
}
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>