v2 : correctifs QA — overlays posés, accueil jamais orphelin, finitions mobiles

- overlays Nuxt UI (UModal/USlideover) : géométrie posée en CSS
  (assets/css/overlays.css) — les utilitaires tailwind attendus par @nuxt/ui
  ne sont pas générés (UnoCSS ne scanne pas node_modules) : drawer mobile et
  modales de confirmation se rendaient hors écran
- a11y : title/description sur les 3 overlays (DialogTitle/Description
  rendus, warnings reka-ui éteints), fermeture du drawer ancrée et testée
- suppression du collectif actif : bascule sur un collectif restant —
  jamais d'accueil orphelin (test store ajouté, 343 vitest verts)
- observatoire : la route « transmis » entre dans les barres (base 100 %)
- overflows mobiles à zéro : formules KaTeX (défilement), table vigueur
  (défilement < 768px), pill cible (retour à la ligne), inputs decider et
  mandat (border-box)
- vérifié navigateur : drawer, modale, 14 routes sans débordement,
  zéro erreur console

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 20:11:54 +02:00
co-authored by Claude Fable 5
parent 38fd053e87
commit 3301b764f7
14 changed files with 194 additions and 9 deletions
+147
View File
@@ -0,0 +1,147 @@
/*
* Overlays Nuxt UI (UModal / USlideover) — géométrie posée à la main.
* Les composants @nuxt/ui portent des classes utilitaires tailwind que ce
* projet ne génère pas (UnoCSS ne scanne pas node_modules) : sans ces règles,
* voile et panneaux se rendent à leur position statique, en fin de <body>,
* donc hors écran. On cible la structure stable posée par reka-ui.
*/
/* Voile */
body > div.fixed.inset-0[data-state] {
position: fixed;
inset: 0;
z-index: 90;
background: rgb(15 20 25 / 0.45);
backdrop-filter: blur(2px);
}
body > div.fixed.inset-0[data-state='open'] {
animation: ldov-fade-in 0.15s ease;
}
/* Panneau latéral (USlideover — drawer mobile) */
body > [role='dialog'][data-side] {
position: fixed;
top: 0;
bottom: 0;
width: min(20rem, 86vw);
z-index: 91;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--mood-surface);
color: var(--mood-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
}
body > [role='dialog'][data-side='left'] {
left: 0;
right: auto;
border-radius: 0 16px 16px 0;
}
body > [role='dialog'][data-side='right'] {
right: 0;
left: auto;
border-radius: 16px 0 0 16px;
}
body > [role='dialog'][data-side='left'][data-state='open'] {
animation: ldov-slide-left 0.18s ease;
}
body > [role='dialog'][data-side='right'][data-state='open'] {
animation: ldov-slide-right 0.18s ease;
}
/* Modale centrée (UModal) */
body > [role='dialog']:not([data-side]) {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(26rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
overflow-y: auto;
z-index: 91;
background: var(--mood-surface);
color: var(--mood-text);
border-radius: 16px;
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
}
body > [role='dialog']:not([data-side])[data-state='open'] {
animation: ldov-pop 0.15s ease;
}
/* Blocs internes générés par Nuxt UI — classes posées via la prop :ui */
.ldov-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 3.5rem 0.5rem 1.25rem;
}
.ldov-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
/* description a11y sans rendu visuel (lecteurs d'écran seulement) */
.ldov-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ldov-desc {
margin: 0;
padding: 0 1.25rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.ldov-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 1.25rem 1.25rem;
}
.ldov-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
min-height: 2.25rem;
border-radius: 14px;
background: var(--mood-input-bg);
color: var(--mood-text);
cursor: pointer;
transition: background 0.1s ease, transform 0.1s ease;
}
.ldov-close:hover {
background: var(--mood-accent-soft);
transform: translateY(-1px);
}
.ldov-close:active {
transform: translateY(0);
}
@keyframes ldov-fade-in {
from { opacity: 0; }
}
@keyframes ldov-slide-left {
from { transform: translateX(-100%); }
}
@keyframes ldov-slide-right {
from { transform: translateX(100%); }
}
@keyframes ldov-pop {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.96);
}
}
@@ -219,6 +219,10 @@ function saveMeasure(index: number) {
border-collapse: collapse; border-collapse: collapse;
font-size: 0.875rem; font-size: 0.875rem;
} }
@media (max-width: 768px) {
/* la table ne sait pas rétrécir sous la largeur de son contenu : elle défile */
.vig__table { display: block; overflow-x: auto; }
}
.vig__table th { .vig__table th {
text-align: left; text-align: left;
font-size: 0.8125rem; font-size: 0.8125rem;
@@ -91,7 +91,11 @@ async function removeConfirmed() {
</li> </li>
</ul> </ul>
<UModal v-model:open="confirmOpen"> <UModal
v-model:open="confirmOpen"
:title="confirmEntry ? `Supprimer « ${confirmEntry.name} » ?` : 'Supprimer'"
description="Ce geste supprime uniquement la copie locale, sur cette machine."
>
<template #content> <template #content>
<div class="confirm"> <div class="confirm">
<h3 class="confirm__title">Supprimer « {{ confirmEntry?.name }} » ?</h3> <h3 class="confirm__title">Supprimer « {{ confirmEntry?.name }} » ?</h3>
@@ -332,7 +332,7 @@ const currentCurve = computed(() => curvePath(M.value, G.value))
.lab { display: flex; flex-direction: column; gap: 1rem; } .lab { display: flex; flex-direction: column; gap: 1rem; }
.lab__formula { padding: clamp(0.9rem, 3vw, 1.4rem); overflow-x: auto; } .lab__formula { padding: clamp(0.9rem, 3vw, 1.4rem); overflow-x: auto; }
.lab__tex { text-align: center; } .lab__tex { text-align: center; max-width: 100%; overflow-x: auto; }
.lab__tex :deep(.lab__tex-fallback) { .lab__tex :deep(.lab__tex-fallback) {
font-family: monospace; font-family: monospace;
font-size: 0.8125rem; font-size: 0.8125rem;
@@ -120,7 +120,7 @@ const OBJECTION_STATUS = { open: 'ouverte', withdrawn: 'levée', integrated: 'in
.vi__target { .vi__target {
font-size: 0.75rem; font-weight: 700; color: var(--mood-tertiary); font-size: 0.75rem; font-weight: 700; color: var(--mood-tertiary);
background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent); background: color-mix(in srgb, var(--mood-tertiary) 12%, transparent);
padding: 1px 8px; border-radius: var(--r-pill); white-space: nowrap; padding: 1px 8px; border-radius: var(--r-pill); overflow-wrap: anywhere;
} }
.vi__engages { .vi__engages {
margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem;
@@ -7,7 +7,7 @@ import { computeMyImpact, detectBimodality, medianByElement, resolveDerived } fr
import { useCollectiveStore } from '~/stores/collective' import { useCollectiveStore } from '~/stores/collective'
import { useDecisionsStore } from '~/stores/decisions' import { useDecisionsStore } from '~/stores/decisions'
import { import {
CRYSTALLIZE_ACTION, CRYSTALLIZE_CARD, CRYSTALLIZE_CHOICES, EXPLORE_DISCLAIMER, CRYSTALLIZE_ACTION, CRYSTALLIZE_CARD, CRYSTALLIZE_CHOICES, CRYSTALLIZE_MODAL_DESC, EXPLORE_DISCLAIMER,
EXPLORE_LABEL, FROZEN_BANNER, IMPACT_CARD_TITLE, IMPACT_DISCLAIMER, EXPLORE_LABEL, FROZEN_BANNER, IMPACT_CARD_TITLE, IMPACT_DISCLAIMER,
MEDIAN_EXPLANATION, PARAM_CONSTRAINT_LABEL, MEDIAN_EXPLANATION, PARAM_CONSTRAINT_LABEL,
} from '~/lexicon' } from '~/lexicon'
@@ -297,7 +297,12 @@ function fullIndexOf(key: string): number {
Cristallisée par {{ crystallizer }} le {{ new Date(session.crystallizedAt).toLocaleDateString('fr-FR') }}. Cristallisée par {{ crystallizer }} le {{ new Date(session.crystallizedAt).toLocaleDateString('fr-FR') }}.
</p> </p>
<UModal v-model:open="showCrystal" :title="CRYSTALLIZE_ACTION"> <UModal
v-model:open="showCrystal"
:title="CRYSTALLIZE_ACTION"
:description="CRYSTALLIZE_MODAL_DESC"
:ui="{ header: 'ldov-header', title: 'ldov-title', description: 'ldov-desc', close: 'ldov-close', body: 'ldov-body' }"
>
<template #body> <template #body>
<div class="vp__modal"> <div class="vp__modal">
<p v-if="medianFull" class="vp__modal-median"> <p v-if="medianFull" class="vp__modal-median">
+2 -1
View File
@@ -135,7 +135,8 @@ async function explore(id: string) {
v-model:open="mobileMenuOpen" v-model:open="mobileMenuOpen"
side="left" side="left"
title="Menu" title="Menu"
:ui="{ width: 'max-w-xs' }" description="Navigation, collectif et ambiance."
:ui="{ header: 'ldov-header', title: 'ldov-title', description: 'ldov-sr-only', close: 'ldov-close', body: 'ldov-body' }"
> >
<template #body> <template #body>
<nav class="app-mobile-nav"> <nav class="app-mobile-nav">
+1
View File
@@ -162,6 +162,7 @@ export const MEDIAN_EXPLANATION =
export const BIMODAL_BANNER = (paramLabel: string) => export const BIMODAL_BANNER = (paramLabel: string) =>
`Deux positions distinctes se dessinent sur ${paramLabel} — scinder la question ou changer de référentiel ?` `Deux positions distinctes se dessinent sur ${paramLabel} — scinder la question ou changer de référentiel ?`
export const CRYSTALLIZE_CHOICES = ['Cristalliser quand même', 'Scinder', 'Reformuler'] as const export const CRYSTALLIZE_CHOICES = ['Cristalliser quand même', 'Scinder', 'Reformuler'] as const
export const CRYSTALLIZE_MODAL_DESC = "Tu figes d'un geste la position que le collectif dessine."
// ── Record & observatory ── // ── Record & observatory ──
export const RECORD_ALT = 'Déjà tranché — je le consigne' export const RECORD_ALT = 'Déjà tranché — je le consigne'
+1
View File
@@ -353,6 +353,7 @@ async function validate() {
} }
.decider__title { .decider__title {
width: 100%; width: 100%;
box-sizing: border-box;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
font-size: clamp(1.125rem, 3.5vw, 1.375rem); font-size: clamp(1.125rem, 3.5vw, 1.375rem);
font-weight: 700; font-weight: 700;
@@ -30,7 +30,7 @@ const filtered = computed(() => col.decisions.filter(d =>
const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft')) const engaged = computed(() => filtered.value.filter(d => d.status !== 'draft'))
// Autonomie : ratio par route, maturité, temps médians, participation // Autonomie : ratio par route, maturité, temps médians, participation
const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'advice', 'collective', 'record'] const ROUTES: DecisionRoute[] = ['solo', 'mandate', 'transmit', 'advice', 'collective', 'record']
const routeBars = computed(() => { const routeBars = computed(() => {
const total = Math.max(1, engaged.value.length) const total = Math.max(1, engaged.value.length)
return ROUTES.map((r) => { return ROUTES.map((r) => {
+1 -1
View File
@@ -345,7 +345,7 @@ function submit(): void {
.mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; } .mwiz__label { font-size: 0.875rem; font-weight: 700; margin-top: 0.375rem; }
.mwiz__label--inline { margin-top: 0; } .mwiz__label--inline { margin-top: 0; }
.mwiz__input { .mwiz__input {
width: 100%; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem; width: 100%; box-sizing: border-box; padding: 0.6875rem 1rem; font: inherit; font-size: 0.9375rem;
color: var(--mood-text); background: var(--mood-input-bg); border: none; color: var(--mood-text); background: var(--mood-input-bg); border: none;
border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border); border-radius: var(--r-input); box-shadow: inset 0 0 0 1px var(--mood-input-border);
} }
+4
View File
@@ -248,6 +248,10 @@ export const useCollectiveStore = defineStore('collective', {
if (this.activeId === id) { if (this.activeId === id) {
this.activeId = null this.activeId = null
this.current = null this.current = null
// Ne jamais laisser la personne devant un accueil orphelin : s'il
// reste un collectif sur la machine, il devient l'actif.
const next = this.index[0]
if (next) await this.switchTo(next.id)
} }
}, },
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineNuxtConfig({
devtools: { enabled: false }, devtools: { enabled: false },
devServer: { port: 3002, host: '0.0.0.0' }, devServer: { port: 3002, host: '0.0.0.0' },
components: [{ path: '~/components', pathPrefix: false }], components: [{ path: '~/components', pathPrefix: false }],
css: ['~/assets/css/moods.css'], css: ['~/assets/css/moods.css', '~/assets/css/overlays.css'],
modules: [ modules: [
'@nuxt/ui', '@nuxt/ui',
'@pinia/nuxt', '@pinia/nuxt',
+18
View File
@@ -572,6 +572,24 @@ describe('collective store', () => {
expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true) expect(second.issues.some(i => i.level === 'error' && i.message.includes('existe déjà'))).toBe(true)
}) })
it('switches to a remaining collective when the active one is removed', async () => {
const col = useCollectiveStore()
const first = await col.importJson(
JSON.stringify(buildTemplateBundle('blank', buildOpts(`reste-${crypto.randomUUID().slice(0, 8)}`))), true)
const second = await col.importJson(
JSON.stringify(buildTemplateBundle('blank', buildOpts(`actif-${crypto.randomUUID().slice(0, 8)}`))), true)
expect(col.activeId).toBe(second.state!.collective.id)
await col.removeCollective(second.state!.collective.id)
// Jamais d'accueil orphelin : le collectif restant devient l'actif.
expect(col.activeId).toBe(first.state!.collective.id)
expect(col.current?.collective.id).toBe(first.state!.collective.id)
await col.removeCollective(first.state!.collective.id)
expect(col.activeId).toBeNull()
expect(col.index.length).toBe(0)
})
it('resolves settings and the me profile from the active state', async () => { it('resolves settings and the me profile from the active state', async () => {
const col = useCollectiveStore() const col = useCollectiveStore()
await col.createFromTemplate('commune', { await col.createFromTemplate('commune', {