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:
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
Reference in New Issue
Block a user