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:
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Stacked initials avatars — deterministic tint from id, -8px overlap.
|
||||
// origin glyphs: 'computed' = full dot (concerné·e en premier lieu),
|
||||
// 'declared' = raised hand (en second lieu). Tap → parent shows inclusion reason.
|
||||
import type { Person } from '~/types/domain'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
people: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
|
||||
max?: number
|
||||
size?: number
|
||||
}>(), { max: 6, size: 30 })
|
||||
|
||||
const emit = defineEmits<{ (e: 'tap', entry: { person: Person; origin?: string; reason?: string }): void }>()
|
||||
|
||||
const visible = computed(() => props.people.slice(0, props.max))
|
||||
const overflow = computed(() => Math.max(0, props.people.length - props.max))
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.split(/[\s-]+/).map(w => w[0] ?? '').join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
function tint(id: string): string {
|
||||
let h = 0
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360
|
||||
return `oklch(0.72 0.09 ${h})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="ld-avatars" :style="{ '--av-size': size + 'px' }">
|
||||
<button
|
||||
v-for="entry in visible"
|
||||
:key="entry.person.id"
|
||||
class="ld-avatars__item"
|
||||
:style="{ background: tint(entry.person.id) }"
|
||||
:title="entry.reason ? `${entry.person.displayName} — ${entry.reason}` : entry.person.displayName"
|
||||
type="button"
|
||||
@click="emit('tap', entry)"
|
||||
>
|
||||
<span>{{ initials(entry.person.displayName) }}</span>
|
||||
<span v-if="entry.origin === 'declared'" class="ld-avatars__glyph">
|
||||
<UIcon name="i-lucide-hand" />
|
||||
</span>
|
||||
<span v-else-if="entry.origin === 'computed'" class="ld-avatars__dot" />
|
||||
</button>
|
||||
<span v-if="overflow > 0" class="ld-avatars__more">+{{ overflow }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-avatars { display: inline-flex; align-items: center; }
|
||||
.ld-avatars__item {
|
||||
position: relative;
|
||||
width: var(--av-size);
|
||||
height: var(--av-size);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(var(--av-size) * 0.36);
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-left: -8px;
|
||||
box-shadow: 0 0 0 2px var(--mood-surface);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.ld-avatars__item:first-child { margin-left: 0; }
|
||||
.ld-avatars__item:hover { transform: translateY(-1px); z-index: 1; }
|
||||
.ld-avatars__glyph {
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
width: calc(var(--av-size) * 0.48);
|
||||
height: calc(var(--av-size) * 0.48);
|
||||
border-radius: 50%;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(var(--av-size) * 0.3);
|
||||
}
|
||||
.ld-avatars__dot {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: calc(var(--av-size) * 0.28);
|
||||
height: calc(var(--av-size) * 0.28);
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
box-shadow: 0 0 0 2px var(--mood-surface);
|
||||
}
|
||||
.ld-avatars__more {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
// <!-- ld-v2 --> Window countdown chip. Suspended (boundary objection) shows the
|
||||
// pause state — the most important state to make visible.
|
||||
import { BOUNDARY_SUSPENDED } from '~/lexicon'
|
||||
|
||||
const props = defineProps<{
|
||||
endsAt?: string
|
||||
suspendedAt?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.suspendedAt) return BOUNDARY_SUSPENDED
|
||||
if (!props.endsAt) return ''
|
||||
const ms = new Date(props.endsAt).getTime() - Date.now()
|
||||
if (ms <= 0) return 'échéance passée'
|
||||
const h = Math.floor(ms / 3_600_000)
|
||||
if (h < 1) return `${Math.max(1, Math.floor(ms / 60_000))} min restantes`
|
||||
if (h < 48) return `${h} h restantes`
|
||||
return `${Math.floor(h / 24)} jours restants`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<span class="ld-countdown" :class="{ 'ld-countdown--paused': suspendedAt }">
|
||||
<UIcon :name="suspendedAt ? 'i-lucide-pause' : 'i-lucide-timer'" />
|
||||
<span>{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-countdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-status-fenetre);
|
||||
background: var(--mood-status-fenetre-bg);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.ld-countdown--paused {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--mood-status-fenetre-bg),
|
||||
var(--mood-status-fenetre-bg) 6px,
|
||||
transparent 6px,
|
||||
transparent 10px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
/** Quatre pastilles rondes — une par ambiance. Ring accent sur l'actif. */
|
||||
const { moods, currentMood, setMood } = useLibreMood()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div class="mood-switcher" role="group" aria-label="Ambiance">
|
||||
<UTooltip v-for="m in moods" :key="m.id" :text="m.label">
|
||||
<button
|
||||
class="mood-dot"
|
||||
:class="{ 'mood-dot--active': currentMood === m.id }"
|
||||
:style="{ background: m.color }"
|
||||
:aria-label="m.label"
|
||||
:aria-pressed="currentMood === m.id"
|
||||
@click="setMood(m.id)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mood-switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.mood-dot {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35), 0 1px 3px var(--mood-shadow);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.mood-dot:hover {
|
||||
transform: translateY(-1px) scale(1.12);
|
||||
}
|
||||
.mood-dot:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.mood-dot--active {
|
||||
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35),
|
||||
0 0 0 2px var(--mood-surface),
|
||||
0 0 0 4px var(--mood-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sceau hexagramme #48 Tsing 井 — géométrie SVG canonique.
|
||||
* viewBox 112×105, 6 traits (trait 1 en bas = dernier rect), coins vifs.
|
||||
* Réutilisable : stamp du logo (miniature), sceau de page (.app-seal), badges.
|
||||
*/
|
||||
const props = withDefaults(defineProps<{ size?: number }>(), { size: 44 })
|
||||
|
||||
const height = computed(() => Math.round((props.size * 105) / 112))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<svg
|
||||
:width="size"
|
||||
:height="height"
|
||||
viewBox="0 0 112 105"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Trait 6 (haut) — yin -->
|
||||
<rect x="6" y="6" width="42" height="8" />
|
||||
<rect x="64" y="6" width="42" height="8" />
|
||||
<!-- Trait 5 — yang -->
|
||||
<rect x="6" y="23" width="100" height="8" />
|
||||
<!-- Trait 4 — yin -->
|
||||
<rect x="6" y="40" width="42" height="8" />
|
||||
<rect x="64" y="40" width="42" height="8" />
|
||||
<!-- Trait 3 — yang -->
|
||||
<rect x="6" y="57" width="100" height="8" />
|
||||
<!-- Trait 2 — yang -->
|
||||
<rect x="6" y="74" width="100" height="8" />
|
||||
<!-- Trait 1 (bas) — yin -->
|
||||
<rect x="6" y="91" width="42" height="8" />
|
||||
<rect x="64" y="91" width="42" height="8" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sélecteur de collectif — pastille couleur + nom + finalité (clause A1 du Pacte)
|
||||
* en une ligne. Menu léger : collectifs locaux, « Créer un collectif », « Données locales ».
|
||||
* Accès défensifs (?.) : le store peut ne pas être hydraté.
|
||||
*/
|
||||
import { useCollectiveStore } from '~/stores/collective'
|
||||
|
||||
const store = useCollectiveStore()
|
||||
const route = useRoute()
|
||||
|
||||
const open = ref(false)
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
onClickOutside(rootEl, () => { open.value = false })
|
||||
watch(() => route.path, () => { open.value = false })
|
||||
|
||||
const current = computed(() => store.current?.collective ?? null)
|
||||
const index = computed(() => store.index ?? [])
|
||||
|
||||
/** Finalité du collectif = contenu de la version courante de la clause A1 du Pacte. */
|
||||
const purpose = computed(() => {
|
||||
const state = store.current
|
||||
if (!state) return ''
|
||||
const clause = state.clauses?.find(c => c.code === 'A1')
|
||||
if (!clause?.currentVersionId) return ''
|
||||
return state.versions?.find(v => v.id === clause.currentVersionId)?.content ?? ''
|
||||
})
|
||||
|
||||
async function pick(id: string) {
|
||||
open.value = false
|
||||
if (id !== store.activeId) await store.switchTo?.(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ld-v2 -->
|
||||
<div ref="rootEl" class="ws">
|
||||
<!-- Aucun collectif : inviter à créer -->
|
||||
<NuxtLink v-if="!current" to="/creer" class="ld-btn ld-btn--ghost ws__create">
|
||||
<UIcon name="i-lucide-sparkles" />
|
||||
<span>Créer</span>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
class="ws__trigger"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="menu"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="ws__dot" :style="{ background: current.color }" />
|
||||
<span class="ws__text">
|
||||
<span class="ws__name">{{ current.name }}</span>
|
||||
<span v-if="purpose" class="ws__purpose">{{ purpose }}</span>
|
||||
</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="ws__chevron"
|
||||
:class="{ 'ws__chevron--open': open }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition name="ws-pop">
|
||||
<div v-if="open" class="ws__menu ld-card" role="menu">
|
||||
<button
|
||||
v-for="entry in index"
|
||||
:key="entry.id"
|
||||
class="ws__item"
|
||||
:class="{ 'ws__item--active': entry.id === store.activeId }"
|
||||
role="menuitem"
|
||||
@click="pick(entry.id)"
|
||||
>
|
||||
<span class="ws__dot" :style="{ background: entry.color }" />
|
||||
<span class="ws__item-name">{{ entry.name }}</span>
|
||||
<UIcon
|
||||
v-if="entry.id === store.activeId"
|
||||
name="i-lucide-check"
|
||||
class="ws__item-check"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="ws__sep" />
|
||||
|
||||
<NuxtLink to="/creer" class="ws__item" role="menuitem">
|
||||
<UIcon name="i-lucide-sparkles" class="ws__item-icon" />
|
||||
<span class="ws__item-name">Créer un collectif</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/donnees" class="ws__item" role="menuitem">
|
||||
<UIcon name="i-lucide-hard-drive" class="ws__item-icon" />
|
||||
<span class="ws__item-name">Données locales</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ws__create {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
.ws__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
max-width: 20rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: none;
|
||||
border-radius: var(--r-input);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.ws__trigger:hover {
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
|
||||
.ws__dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.ws__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ws__name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ws__purpose {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.ws__chevron {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.ws__chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* --- Menu --- */
|
||||
.ws__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
min-width: 15rem;
|
||||
max-width: 20rem;
|
||||
padding: 0.375rem;
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.ws__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border-radius: var(--r-input);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.ws__item:hover {
|
||||
background: var(--mood-accent-soft);
|
||||
}
|
||||
.ws__item--active {
|
||||
color: var(--mood-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ws__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ws__item-check {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws__item-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--mood-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws__sep {
|
||||
height: 1px;
|
||||
margin: 0.375rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* --- Transition --- */
|
||||
.ws-pop-enter-active,
|
||||
.ws-pop-leave-active {
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
.ws-pop-enter-from,
|
||||
.ws-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user