forked from yvv/decision
v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Réglage collectif — décider au curseur. Faisceau anonyme,
|
||||
// médiane basse nommée, carte « Pour moi » (jamais de chiffre inventé),
|
||||
// sandbox Explorer, et la CRISTALLISATION : un geste humain daté du garant,
|
||||
// solennel mais léger — jamais un automatisme.
|
||||
import { computeMyImpact, detectBimodality, medianByElement, resolveDerived } from '~/engine'
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
import { useDecisionsStore } from '~/stores/decisions'
|
||||
import {
|
||||
CRYSTALLIZE_ACTION, CRYSTALLIZE_CARD, CRYSTALLIZE_CHOICES, EXPLORE_DISCLAIMER,
|
||||
EXPLORE_LABEL, FROZEN_BANNER, IMPACT_CARD_TITLE, IMPACT_DISCLAIMER,
|
||||
MEDIAN_EXPLANATION, PARAM_CONSTRAINT_LABEL,
|
||||
} from '~/lexicon'
|
||||
import type { Decision, Id, ParamSpec, Protocol, VoteSession } from '~/types/domain'
|
||||
|
||||
const props = defineProps<{
|
||||
decision: Decision
|
||||
session: VoteSession
|
||||
protocol: Protocol
|
||||
secret?: boolean
|
||||
canAct?: boolean
|
||||
asPersonId?: Id
|
||||
isSteward?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'adopted'): void }>()
|
||||
|
||||
const col = useCollectiveStore()
|
||||
const store = useDecisionsStore()
|
||||
|
||||
const spec = computed<ParamSpec>(() => props.decision.paramSpec!)
|
||||
const votable = computed(() => spec.value.params.filter(p => p.derived !== true))
|
||||
/** Index votable → index complet (ordre spec.params). */
|
||||
const votableIndex = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
votable.value.forEach((p, i) => map.set(p.key, i))
|
||||
return map
|
||||
})
|
||||
|
||||
// ── Les positions déposées (derniers votes actifs, toujours recalculées) ──
|
||||
const active = computed(() => store.activeVotes(props.session.id))
|
||||
const vectors = computed(() => active.value
|
||||
.map(v => v.values)
|
||||
.filter((v): v is number[] => v !== undefined && v.length === votable.value.length))
|
||||
const fullVectors = computed(() => vectors.value.map(v => resolveDerived(spec.value, v)))
|
||||
const medianVotable = computed(() => medianByElement(vectors.value))
|
||||
const medianFull = computed(() =>
|
||||
medianVotable.value.length === votable.value.length && votable.value.length > 0
|
||||
? resolveDerived(spec.value, medianVotable.value)
|
||||
: null)
|
||||
const baselineFull = computed(() => spec.value.params.map(p => p.baseline ?? p.min))
|
||||
const bimodalKeys = computed(() => votable.value
|
||||
.filter((_, i) => detectBimodality(vectors.value.map(v => v[i] ?? Number.NaN)))
|
||||
.map(p => p.key))
|
||||
|
||||
function columnFor(fullIndex: number): number[] {
|
||||
return fullVectors.value.map(v => v[fullIndex] ?? Number.NaN).filter(Number.isFinite)
|
||||
}
|
||||
|
||||
// ── Ma position de travail ──
|
||||
const voterId = computed(() => props.asPersonId ?? col.me?.id)
|
||||
const myVote = computed(() => active.value.find(v => v.voterId === voterId.value))
|
||||
const working = ref<number[]>(
|
||||
(myVote.value?.values && [...myVote.value.values])
|
||||
?? votable.value.map(p => p.baseline ?? p.min),
|
||||
)
|
||||
const workingFull = computed(() => resolveDerived(spec.value, working.value))
|
||||
|
||||
const round6 = (v: number) => Math.round(v * 1e6) / 1e6
|
||||
/** Nouvelle liste avec la valeur vi ajustée : bornes, pas, et part calculée gardée dans ses bornes. */
|
||||
function adjusted(list: number[], vi: number, raw: number): number[] {
|
||||
const p = votable.value[vi]
|
||||
if (!p || !Number.isFinite(raw)) return list
|
||||
const snap = (v: number) => round6(p.min + Math.round((v - p.min) / p.step) * p.step)
|
||||
let v = snap(Math.min(p.max, Math.max(p.min, raw)))
|
||||
const next = [...list]
|
||||
const derived = spec.value.params.find(q => q.derived === true)
|
||||
if (spec.value.constraint === 'sum100' && p.kind === 'share' && derived) {
|
||||
const otherSum = votable.value.reduce(
|
||||
(sum, q, j) => sum + (q.kind === 'share' && j !== vi ? (next[j] ?? 0) : 0), 0)
|
||||
const lo = 100 - derived.max - otherSum
|
||||
const hi = 100 - derived.min - otherSum
|
||||
v = snap(Math.min(hi, Math.max(lo, v)))
|
||||
if (v > hi + 1e-6) v = round6(v - p.step)
|
||||
if (v < lo - 1e-6) v = round6(v + p.step)
|
||||
v = Math.min(p.max, Math.max(p.min, v))
|
||||
}
|
||||
next[vi] = v
|
||||
return next
|
||||
}
|
||||
function setWorking(key: string, v: number) {
|
||||
const vi = votableIndex.value.get(key)
|
||||
if (vi !== undefined) working.value = adjusted(working.value, vi, v)
|
||||
}
|
||||
|
||||
const error = ref('')
|
||||
function deposit() {
|
||||
error.value = ''
|
||||
const result = store.castVote(props.session.id, {
|
||||
values: [...working.value],
|
||||
...(props.asPersonId ? { asPersonId: props.asPersonId } : {}),
|
||||
})
|
||||
if ('ok' in result) error.value = result.reason
|
||||
}
|
||||
|
||||
// ── Carte « Pour moi » — estimation sur données déclarées, sinon rien ──
|
||||
const attrKey = computed(() => spec.value.impactAttrKey)
|
||||
const myAttr = computed(() =>
|
||||
attrKey.value !== undefined ? col.me?.attributes?.[attrKey.value] : undefined)
|
||||
const declaredAttrs = computed(() => {
|
||||
const key = attrKey.value
|
||||
if (key === undefined) return []
|
||||
return col.people
|
||||
.filter(p => props.session.corpusPersonIds.includes(p.id))
|
||||
.map(p => p.attributes?.[key])
|
||||
.filter((v): v is number => typeof v === 'number')
|
||||
})
|
||||
const resources = computed(() => props.decision.resources ?? { note: '' })
|
||||
const impactMine = computed(() =>
|
||||
computeMyImpact(spec.value, resources.value, workingFull.value, myAttr.value, declaredAttrs.value))
|
||||
const impactMedian = computed(() => medianFull.value
|
||||
? computeMyImpact(spec.value, resources.value, medianFull.value, myAttr.value, declaredAttrs.value)
|
||||
: null)
|
||||
const impactBase = computed(() =>
|
||||
computeMyImpact(spec.value, resources.value, baselineFull.value, myAttr.value, declaredAttrs.value))
|
||||
const unit = computed(() => props.decision.resources?.unit ?? '')
|
||||
const fmtAmount = (v?: number) =>
|
||||
v === undefined ? '—' : `${v.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} ${unit.value}`
|
||||
|
||||
// ── Explorer — exploration, ne compte pas ──
|
||||
const exploring = ref(false)
|
||||
const sandbox = ref<number[]>([])
|
||||
function toggleExplore() {
|
||||
if (exploring.value) { exploring.value = false; return }
|
||||
sandbox.value = [...working.value]
|
||||
exploring.value = true
|
||||
}
|
||||
function setSandbox(key: string, v: number) {
|
||||
const vi = votableIndex.value.get(key)
|
||||
if (vi !== undefined) sandbox.value = adjusted(sandbox.value, vi, v)
|
||||
}
|
||||
const sandboxFull = computed(() =>
|
||||
sandbox.value.length === votable.value.length ? resolveDerived(spec.value, sandbox.value) : [])
|
||||
const seedMedian = () => { if (medianVotable.value.length) sandbox.value = [...medianVotable.value] }
|
||||
const seedBase = () => { sandbox.value = votable.value.map(p => p.baseline ?? p.min) }
|
||||
const seedMine = () => { sandbox.value = [...working.value] }
|
||||
function makeMine() { working.value = [...sandbox.value]; exploring.value = false }
|
||||
|
||||
// ── Cristallisation — le geste du garant ──
|
||||
const frozen = computed(() => props.session.status === 'frozen')
|
||||
const locked = computed(() => props.session.status !== 'open' || !props.canAct)
|
||||
const showCrystal = ref(false)
|
||||
const quorum = computed(() => props.protocol.formula.parametricMinParticipants)
|
||||
const quorumShort = computed(() =>
|
||||
quorum.value !== undefined && vectors.value.length < quorum.value)
|
||||
function doCrystallize() {
|
||||
error.value = ''
|
||||
const result = store.crystallize(props.session.id)
|
||||
if ('ok' in result) { error.value = result.reason; return }
|
||||
showCrystal.value = false
|
||||
if (result.outcome === 'adopted') emit('adopted')
|
||||
}
|
||||
function split() {
|
||||
navigateTo({ path: '/decider', query: { parent: props.decision.id, chain: 'element' } })
|
||||
}
|
||||
function reframe() {
|
||||
error.value = ''
|
||||
const result = store.transition(props.decision.id, 'framing')
|
||||
if (!result.ok) { error.value = result.reason; return }
|
||||
showCrystal.value = false
|
||||
}
|
||||
const crystallizer = computed(() => props.session.crystallizedById
|
||||
? col.people.find(p => p.id === props.session.crystallizedById)?.displayName ?? '—'
|
||||
: null)
|
||||
|
||||
const shareParams = computed(() => spec.value.constraint === 'sum100'
|
||||
? spec.value.params.filter(p => p.kind === 'share')
|
||||
: [])
|
||||
const plainParams = computed(() => spec.value.constraint === 'sum100'
|
||||
? spec.value.params.filter(p => p.kind !== 'share')
|
||||
: spec.value.params)
|
||||
function fullIndexOf(key: string): number {
|
||||
return spec.value.params.findIndex(p => p.key === key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<section class="ld-card vp">
|
||||
<p class="vp__explain">{{ MEDIAN_EXPLANATION }}</p>
|
||||
|
||||
<p v-if="frozen" class="vp__frozen">
|
||||
<UIcon name="i-lucide-lock" />
|
||||
<span>{{ FROZEN_BANNER }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Le cadre contraint (parts à 100) puis les curseurs libres -->
|
||||
<div v-if="shareParams.length" class="vp__frame">
|
||||
<span class="vp__frame-tag">{{ PARAM_CONSTRAINT_LABEL }} — les parts totalisent 100</span>
|
||||
<VoteParamSlider
|
||||
v-for="p in shareParams"
|
||||
:key="p.key"
|
||||
:param="p"
|
||||
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
|
||||
:dots="columnFor(fullIndexOf(p.key))"
|
||||
:median="medianFull?.[fullIndexOf(p.key)]"
|
||||
:locked="locked"
|
||||
:bimodal="bimodalKeys.includes(p.key)"
|
||||
@update:value="setWorking(p.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
<VoteParamSlider
|
||||
v-for="p in plainParams"
|
||||
:key="p.key"
|
||||
:param="p"
|
||||
:value="workingFull[fullIndexOf(p.key)] ?? p.min"
|
||||
:dots="columnFor(fullIndexOf(p.key))"
|
||||
:median="medianFull?.[fullIndexOf(p.key)]"
|
||||
:locked="locked"
|
||||
:bimodal="bimodalKeys.includes(p.key)"
|
||||
@update:value="setWorking(p.key, $event)"
|
||||
/>
|
||||
|
||||
<div v-if="session.status === 'open'" class="vp__actions">
|
||||
<button class="ld-btn" type="button" :disabled="!canAct" @click="deposit()">
|
||||
{{ myVote ? 'Remplacer ma position' : 'Déposer ma position' }}
|
||||
</button>
|
||||
<button class="ld-btn ld-btn--ghost" type="button" @click="toggleExplore()">
|
||||
<UIcon name="i-lucide-flask-conical" />
|
||||
<span>{{ EXPLORE_LABEL }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="vp__error">{{ error }}</p>
|
||||
|
||||
<!-- Sandbox Explorer -->
|
||||
<div v-if="exploring" class="vp__sandbox">
|
||||
<p class="vp__sandbox-banner">
|
||||
<UIcon name="i-lucide-flask-conical" />
|
||||
<span>{{ EXPLORE_DISCLAIMER }}</span>
|
||||
</p>
|
||||
<div class="vp__sandbox-seeds">
|
||||
<button class="ld-btn ld-btn--quiet" type="button" :disabled="!medianVotable.length" @click="seedMedian()">Partir de la médiane</button>
|
||||
<button class="ld-btn ld-btn--quiet" type="button" @click="seedBase()">Partir du statu quo</button>
|
||||
<button class="ld-btn ld-btn--quiet" type="button" @click="seedMine()">Partir de ma position</button>
|
||||
</div>
|
||||
<VoteParamSlider
|
||||
v-for="p in spec.params"
|
||||
:key="p.key"
|
||||
:param="p"
|
||||
:value="sandboxFull[fullIndexOf(p.key)] ?? p.min"
|
||||
neutral
|
||||
@update:value="setSandbox(p.key, $event)"
|
||||
/>
|
||||
<button class="ld-btn" type="button" :disabled="session.status !== 'open'" @click="makeMine()">
|
||||
En faire ma position
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte « Pour moi » — masquée si rien de déclaré, jamais de chiffre inventé -->
|
||||
<div v-if="impactMine && impactBase" class="vp__impact">
|
||||
<h3 class="vp__impact-title">{{ IMPACT_CARD_TITLE }}</h3>
|
||||
<table class="vp__impact-table">
|
||||
<thead>
|
||||
<tr><th /><th>ma position</th><th>médiane</th><th>statu quo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(line, i) in impactMine.perParam" :key="line.key">
|
||||
<th>{{ line.label }}</th>
|
||||
<td>{{ fmtAmount(line.amount) }}</td>
|
||||
<td>{{ fmtAmount(impactMedian?.perParam[i]?.amount) }}</td>
|
||||
<td>{{ fmtAmount(impactBase.perParam[i]?.amount) }}</td>
|
||||
</tr>
|
||||
<tr class="vp__impact-total">
|
||||
<th>total</th>
|
||||
<td>{{ fmtAmount(impactMine.total) }}</td>
|
||||
<td>{{ fmtAmount(impactMedian?.total) }}</td>
|
||||
<td>{{ fmtAmount(impactBase.total) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="vp__impact-note">{{ IMPACT_DISCLAIMER }}</p>
|
||||
</div>
|
||||
|
||||
<p class="vp__count">
|
||||
{{ vectors.length }} position{{ vectors.length > 1 ? 's' : '' }} déposée{{ vectors.length > 1 ? 's' : '' }}
|
||||
<template v-if="quorum !== undefined"> · quorum : {{ quorum }}</template>
|
||||
</p>
|
||||
|
||||
<!-- Le geste du garant -->
|
||||
<div v-if="frozen && isSteward" class="vp__crystal">
|
||||
<p class="vp__crystal-card">{{ CRYSTALLIZE_CARD }}</p>
|
||||
<button class="vp__stamp-btn" type="button" @click="showCrystal = true">
|
||||
<UIcon name="i-lucide-stamp" />
|
||||
<span>{{ CRYSTALLIZE_ACTION }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="crystallizer && session.crystallizedAt" class="vp__crystallized">
|
||||
Cristallisée par {{ crystallizer }} le {{ new Date(session.crystallizedAt).toLocaleDateString('fr-FR') }}.
|
||||
</p>
|
||||
|
||||
<UModal v-model:open="showCrystal" :title="CRYSTALLIZE_ACTION">
|
||||
<template #body>
|
||||
<div class="vp__modal">
|
||||
<p v-if="medianFull" class="vp__modal-median">
|
||||
<template v-for="(p, i) in spec.params" :key="p.key">
|
||||
<span>{{ p.label }} : <strong>{{ (medianFull[i] ?? 0).toLocaleString('fr-FR', { maximumFractionDigits: 2 }) }}</strong>{{ p.unit ? ` ${p.unit}` : '' }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="quorumShort" class="vp__modal-warn">
|
||||
Le quorum n'est pas atteint ({{ vectors.length }} sur {{ quorum }}) — le geste constatera le rejet.
|
||||
</p>
|
||||
<p v-for="key in bimodalKeys" :key="key" class="vp__modal-warn">
|
||||
<UIcon name="i-lucide-split" />
|
||||
<span>{{ votable.find(p => p.key === key)?.label }} : deux positions distinctes se dessinent.</span>
|
||||
</p>
|
||||
<div class="vp__modal-choices">
|
||||
<template v-if="bimodalKeys.length">
|
||||
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_CHOICES[0] }}</button>
|
||||
<button class="ld-btn ld-btn--ghost" type="button" @click="split()">{{ CRYSTALLIZE_CHOICES[1] }}</button>
|
||||
<button class="ld-btn ld-btn--ghost" type="button" @click="reframe()">{{ CRYSTALLIZE_CHOICES[2] }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="ld-btn" type="button" @click="doCrystallize()">{{ CRYSTALLIZE_ACTION }}</button>
|
||||
<button class="ld-btn ld-btn--quiet" type="button" @click="showCrystal = false">Annuler</button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="error" class="vp__error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vp { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
|
||||
.vp__explain { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); line-height: 1.5; }
|
||||
.vp__frozen {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0;
|
||||
padding: 0.6rem 0.9rem; border-radius: var(--r-input);
|
||||
background: var(--mood-status-fige-bg); color: var(--mood-status-fige);
|
||||
font-weight: 700; font-size: 0.9375rem;
|
||||
}
|
||||
.vp__frame {
|
||||
display: flex; flex-direction: column; gap: 0.9rem;
|
||||
padding: 0.9rem; border-radius: var(--r-input);
|
||||
background: var(--mood-bg);
|
||||
}
|
||||
.vp__frame-tag {
|
||||
align-self: flex-start; font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--mood-text-muted); background: var(--mood-surface);
|
||||
padding: 2px 10px; border-radius: var(--r-pill);
|
||||
}
|
||||
.vp__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.vp__error { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-error); }
|
||||
.vp__sandbox {
|
||||
display: flex; flex-direction: column; gap: 0.9rem;
|
||||
padding: 0.9rem; border-radius: var(--r-input);
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 7%, var(--mood-bg));
|
||||
}
|
||||
.vp__sandbox-banner {
|
||||
display: flex; align-items: center; gap: 0.45rem; margin: 0;
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--mood-text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.vp__sandbox-seeds { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.vp__sandbox > .ld-btn { align-self: flex-start; }
|
||||
.vp__impact {
|
||||
background: var(--mood-accent-soft); border-radius: var(--r-input);
|
||||
padding: 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
.vp__impact-title { margin: 0; font-size: 0.9375rem; font-weight: 800; color: var(--mood-accent); }
|
||||
.vp__impact-table { border-collapse: collapse; font-size: 0.875rem; width: 100%; }
|
||||
.vp__impact-table th, .vp__impact-table td { text-align: right; padding: 0.2rem 0.45rem; }
|
||||
.vp__impact-table tbody th { text-align: left; font-weight: 600; }
|
||||
.vp__impact-table thead th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--mood-text-muted); }
|
||||
.vp__impact-total { font-weight: 800; }
|
||||
.vp__impact-note { margin: 0; font-size: 0.75rem; font-style: italic; color: var(--mood-text-muted); }
|
||||
.vp__count { margin: 0; font-size: 0.8125rem; color: var(--mood-text-muted); }
|
||||
.vp__crystal {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.6rem;
|
||||
padding: 1rem; border-radius: var(--r-input); background: var(--mood-status-fige-bg);
|
||||
}
|
||||
.vp__crystal-card { margin: 0; font-weight: 600; font-size: 0.9375rem; text-align: center; }
|
||||
.vp__stamp-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.65rem 1.5rem; border-radius: 10px; cursor: pointer;
|
||||
transform: rotate(-2deg);
|
||||
font-weight: 800; font-size: 1rem; letter-spacing: 0.03em;
|
||||
color: var(--mood-accent); background: var(--mood-surface);
|
||||
box-shadow: inset 0 0 0 2.5px var(--mood-accent), 0 2px 8px var(--mood-shadow);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.vp__stamp-btn:hover { transform: rotate(-2deg) translateY(-1px); }
|
||||
.vp__stamp-btn:active { transform: rotate(-2deg) translateY(0); }
|
||||
.vp__crystallized { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--mood-status-vigueur); }
|
||||
.vp__modal { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.vp__modal-median { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.9375rem; }
|
||||
.vp__modal-warn {
|
||||
display: flex; align-items: baseline; gap: 0.4rem; margin: 0;
|
||||
font-size: 0.875rem; font-weight: 600; color: var(--mood-warning);
|
||||
}
|
||||
.vp__modal-choices { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user