Files
decision/frontend/app/pages/protocols/index.vue
Yvv 79e468b40f Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)
- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:17:14 +02:00

1189 lines
28 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(),
])
})
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' },
],
},
]
/** 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>
<!-- 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>
</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; }
</style>