v2 : couche données complète + shell + primitives communes

- stores/collective.ts (état local-first, gabarits, import/export, seeds)
  + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→
  sessions→cristallisation→épreuve du réel→révocation)
- data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord,
  5 points de départ, Institution symétrique)
- composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch
  (index unique Cmd+K/Q0)
- shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/
  Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1,
  LdAvatarStack (premier/second lieu), LdCountdown (suspension striée)
- 338 tests vitest verts, npm run build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 10:30:35 +02:00
co-authored by Claude Fable 5
parent 6f2bf62295
commit d886302b59
18 changed files with 4107 additions and 957 deletions
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
/** Layout minimal (onboarding /creer) — fond d'ambiance, contenu centré, sceau discret. */
</script>
<template>
<!-- ld-v2 -->
<div class="bare">
<main class="bare__main">
<slot />
</main>
<LdSeal class="app-seal bare__seal" />
</div>
</template>
<style scoped>
.bare {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--mood-bg);
color: var(--mood-text);
}
.bare__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(1.25rem, 4vw, 3rem);
}
.bare__seal {
position: fixed;
right: 1.25rem;
bottom: 1rem;
}
</style>
+608
View File
@@ -0,0 +1,608 @@
<script setup lang="ts">
/**
* Shell v2 — header sticky, sidebar 4 entrées, drawer mobile, FAB « Décider »,
* sceau 井 bas-droite, footer. Rien tant que le store n'est pas prêt (flash évité) ;
* plein-écran d'accueil quand aucun collectif n'existe encore.
*/
import { useCollectiveStore } from '~/stores/collective'
import { CLOSING_LINE, CREATE_SIGNATURE, OPENING_DREAM } from '~/lexicon'
const store = useCollectiveStore()
const route = useRoute()
// ── Navigation : 4 entrées, pas une de plus ──
const navigationItems = [
{ label: "Aujourd'hui", icon: 'i-lucide-sun-medium', to: '/' },
{ label: 'Décisions', icon: 'i-lucide-scale', to: '/decisions' },
{ label: 'Textes', icon: 'i-lucide-book-open', to: '/textes' },
{ label: 'Mandats', icon: 'i-lucide-key-round', to: '/mandats' },
]
function isActive(to: string) {
if (to === '/') return route.path === '/'
return route.path === to || route.path.startsWith(to + '/')
}
// ── Drawer mobile ──
const mobileMenuOpen = ref(false)
watch(() => route.path, () => { mobileMenuOpen.value = false })
// ── Sidebar repliable, persistée ──
const SIDEBAR_KEY = 'ld2-sidebar'
const sidebarCollapsed = ref(false)
watch(sidebarCollapsed, val => localStorage.setItem(SIDEBAR_KEY, String(val)))
onMounted(() => {
const saved = localStorage.getItem(SIDEBAR_KEY)
if (saved !== null) sidebarCollapsed.value = saved === 'true'
})
// ── Accueil : explorer un collectif d'exemple (tolérant si loadSeed absent) ──
const seeds = [
{ id: 'duniter-g1', label: 'Explorer Duniter Ğ1' },
{ id: 'atelier-du-canal', label: "Explorer l'Atelier du Canal" },
]
const loadingSeed = ref<string | null>(null)
async function explore(id: string) {
const s = store as unknown as { loadSeed?: (id: string) => unknown }
if (typeof s.loadSeed === 'function') {
loadingSeed.value = id
try {
await s.loadSeed(id)
} finally {
loadingSeed.value = null
}
} else {
await navigateTo('/creer')
}
}
</script>
<template>
<!-- ld-v2 -->
<div v-if="store.ready" class="app-shell">
<!-- Accueil plein écran aucun collectif -->
<div v-if="!store.current" class="app-welcome">
<div class="app-welcome__logo">
<span class="logo-stamp logo-stamp--lg">
<LdSeal :size="26" />
</span>
<span class="logo-text">
<span class="logo-text__libre">libre</span><span class="logo-text__decision">Decision</span>
</span>
</div>
<h1 class="app-welcome__dream">{{ OPENING_DREAM }}</h1>
<p class="app-welcome__line">{{ CLOSING_LINE }}</p>
<div class="app-welcome__actions">
<NuxtLink to="/creer" class="ld-btn">
<UIcon name="i-lucide-sparkles" />
<span>Créer un collectif</span>
</NuxtLink>
<button
v-for="seed in seeds"
:key="seed.id"
class="ld-btn ld-btn--ghost"
:disabled="loadingSeed !== null"
@click="explore(seed.id)"
>
<UIcon name="i-lucide-compass" />
<span>{{ seed.label }}</span>
</button>
</div>
<LdSeal class="app-seal app-welcome__seal" />
</div>
<!-- Shell ordinaire -->
<template v-else>
<!-- Header -->
<header class="app-header">
<div class="app-header__inner">
<div class="app-header__left">
<button
class="app-header__menu-btn"
aria-label="Ouvrir le menu"
@click="mobileMenuOpen = true"
>
<UIcon name="i-lucide-menu" class="app-header__menu-icon" />
</button>
<NuxtLink to="/" class="app-header__logo">
<span class="logo-stamp">
<LdSeal :size="18" />
</span>
<span class="logo-text">
<span class="logo-text__libre">libre</span><span class="logo-text__decision">Decision</span>
</span>
</NuxtLink>
</div>
<div class="app-header__center">
<LdWorkspaceSelector />
<LdMoodSwitcher />
</div>
<div class="app-header__right">
<NuxtLink to="/decider" class="ld-btn app-header__decide">
Décider
</NuxtLink>
</div>
</div>
</header>
<!-- Drawer mobile -->
<USlideover
v-model:open="mobileMenuOpen"
side="left"
title="Menu"
:ui="{ width: 'max-w-xs' }"
>
<template #body>
<nav class="app-mobile-nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-mobile-nav__link"
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
@click="mobileMenuOpen = false"
>
<UIcon :name="item.icon" class="app-mobile-nav__icon" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
<div class="app-mobile-row">
<span class="app-mobile-row__label">Collectif</span>
<LdWorkspaceSelector />
</div>
<div class="app-mobile-row">
<span class="app-mobile-row__label">Ambiance</span>
<LdMoodSwitcher />
</div>
</template>
</USlideover>
<!-- Corps : sidebar + page -->
<div class="app-body">
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
<nav class="app-sidebar__nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-sidebar__link"
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
>
<UIcon :name="item.icon" class="app-sidebar__icon" />
<span class="app-sidebar__link-label">{{ item.label }}</span>
</NuxtLink>
<div class="app-sidebar__divider" />
<button
class="app-sidebar__toggle"
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<UIcon
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
class="app-sidebar__icon"
/>
<span class="app-sidebar__link-label">Replier</span>
</button>
</nav>
</aside>
<main class="app-main">
<slot />
<LdSeal class="app-seal app-main__seal" />
</main>
</div>
<!-- FAB mobile « Décider » au-dessus du sceau, qui reste visible -->
<NuxtLink to="/decider" class="app-fab" aria-label="Décider">
<UIcon name="i-lucide-zap" class="app-fab__icon" />
</NuxtLink>
<!-- Footer -->
<footer class="app-footer">
<span>libreDecision v2</span>
<span class="app-footer__sep">·</span>
<span>{{ CREATE_SIGNATURE }}</span>
</footer>
</template>
</div>
<!-- !ready : rien le flash est évité -->
</template>
<style scoped>
/* === Shell === */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
background: var(--mood-bg);
color: var(--mood-text);
}
/* === Logo (partagé header / accueil) === */
.logo-stamp {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--mood-accent);
color: var(--mood-accent-text);
border-radius: 8px;
transform: rotate(-10deg);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.app-header__logo:hover .logo-stamp {
transform: rotate(-16deg) scale(1.08);
}
.logo-stamp--lg {
width: 2.75rem;
height: 2.75rem;
border-radius: 10px;
}
.logo-text {
display: flex;
align-items: baseline;
letter-spacing: -0.01em;
}
.logo-text__libre {
font-size: 1.0625rem;
font-weight: 400;
font-style: italic;
color: var(--mood-text-muted);
}
.logo-text__decision {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
/* === Accueil plein écran === */
.app-welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: clamp(1.5rem, 5vw, 4rem);
text-align: center;
background: var(--mood-gradient);
}
.app-welcome__logo {
display: flex;
align-items: center;
gap: 0.875rem;
margin-bottom: 0.5rem;
}
.app-welcome__logo .logo-text__libre { font-size: 1.375rem; }
.app-welcome__logo .logo-text__decision { font-size: 1.5rem; }
.app-welcome__dream {
max-width: 34rem;
font-size: clamp(1.5rem, 4.5vw, 2.25rem);
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.02em;
margin: 0;
}
.app-welcome__line {
margin: 0;
font-size: 1rem;
font-style: italic;
color: var(--mood-text-muted);
}
.app-welcome__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
}
.app-welcome__seal {
position: fixed;
right: 1.25rem;
bottom: 1rem;
}
/* === Header === */
.app-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--mood-surface);
box-shadow: 0 1px 2px var(--mood-shadow);
}
.app-header__inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
height: 3.5rem;
}
.app-header__left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__logo {
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: var(--r-input);
}
.app-header__menu-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__menu-icon { font-size: 1.25rem; }
.app-header__center {
display: none;
align-items: center;
gap: 1rem;
flex: 1;
justify-content: center;
min-width: 0;
}
.app-header__right {
display: flex;
align-items: center;
}
.app-header__decide {
display: none;
}
@media (min-width: 768px) {
.app-header__menu-btn { display: none; }
.app-header__center { display: flex; }
.app-header__decide { display: inline-flex; }
}
/* === Corps + sidebar === */
.app-body {
display: flex;
flex: 1;
}
.app-sidebar {
width: 13rem;
flex-shrink: 0;
background: var(--mood-surface);
display: none;
transition: width 0.22s ease;
overflow: hidden;
}
.app-sidebar--collapsed { width: 3.75rem; }
@media (min-width: 768px) {
.app-sidebar { display: block; }
}
.app-sidebar__nav {
position: sticky;
top: 3.5rem;
padding: 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-sidebar__link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: var(--r-input);
transition: all 0.12s ease;
white-space: nowrap;
overflow: hidden;
}
.app-sidebar__link:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.app-sidebar__icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.app-sidebar__link-label {
overflow: hidden;
white-space: nowrap;
transition: opacity 0.18s ease, max-width 0.22s ease;
max-width: 10rem;
}
.app-sidebar--collapsed .app-sidebar__link-label {
opacity: 0;
max-width: 0;
}
.app-sidebar--collapsed .app-sidebar__link {
justify-content: center;
padding: 0.625rem;
}
.app-sidebar__divider {
height: 1px;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
margin: 0.375rem 0.25rem;
}
.app-sidebar__toggle {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
background: none;
cursor: pointer;
border-radius: var(--r-input);
width: 100%;
transition: all 0.12s ease;
white-space: nowrap;
overflow: hidden;
}
.app-sidebar__toggle:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar--collapsed .app-sidebar__toggle {
justify-content: center;
padding: 0.5rem;
}
/* === Drawer mobile === */
.app-mobile-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.75rem;
}
.app-mobile-nav__link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.25rem;
font-size: 1.0625rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: var(--r-icon);
min-height: 3rem;
}
.app-mobile-nav__link:hover,
.app-mobile-nav__link:active {
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.app-mobile-nav__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.app-mobile-nav__icon { font-size: 1.125rem; }
.app-mobile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
margin-top: 0.5rem;
}
.app-mobile-row__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
flex-shrink: 0;
}
/* === Main + sceau === */
.app-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 1.5rem 1.25rem;
}
@media (min-width: 640px) {
.app-main { padding: 2rem 1.75rem; }
}
@media (min-width: 1024px) {
.app-main { padding: 2rem 2.5rem; }
}
.app-main__seal {
margin: auto 0 0.5rem auto;
padding-top: 1.5rem;
box-sizing: content-box;
}
/* === FAB mobile === */
.app-fab {
position: fixed;
right: 1rem;
bottom: 4.75rem; /* décalage vertical : le sceau reste visible dessous */
z-index: 35;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--mood-accent);
color: var(--mood-accent-text);
box-shadow: var(--shadow-raised);
text-decoration: none;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.app-fab:hover {
transform: translateY(-1px);
}
.app-fab:active {
transform: translateY(0);
}
.app-fab__icon { font-size: 1.5rem; }
@media (min-width: 768px) {
.app-fab { display: none; }
}
/* === Footer === */
.app-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.app-footer__sep { opacity: 0.3; }
</style>