v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal

- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+179
View File
@@ -0,0 +1,179 @@
// ─────────────────────────────────────────────────────────────
// libreDecision v2 — local-first persistence (IndexedDB via idb-keyval).
// One key per collective: `ld2:<collectiveId>` holds a Bundle-shaped state.
// Seeds and user imports go through the SAME importBundle() path.
// Sync-ready invariants live in types/domain.ts — this layer stays dumb.
// ─────────────────────────────────────────────────────────────
import { get, set, del, keys } from 'idb-keyval'
import type { Bundle, Collective, Id, ISODate } from '~/types/domain'
const KEY_PREFIX = 'ld2:'
const ACTIVE_KEY = 'ld2-active'
/** In-memory state of one collective — exactly a Bundle minus export metadata. */
export type CollectiveState = Omit<Bundle, 'schemaVersion' | 'exportedAt'>
export function emptyState(collective: Collective): CollectiveState {
return {
collective,
people: [],
circles: [],
decisions: [],
concerns: [],
objections: [],
advices: [],
assents: [],
mandates: [],
docs: [],
clauses: [],
versions: [],
protocols: [],
sessions: [],
votes: [],
}
}
// ── Load / save ──────────────────────────────────────────────
export async function listCollectiveIds(): Promise<Id[]> {
const all = await keys()
return all
.filter((k): k is string => typeof k === 'string' && k.startsWith(KEY_PREFIX))
.map(k => k.slice(KEY_PREFIX.length))
}
export async function loadState(collectiveId: Id): Promise<CollectiveState | undefined> {
return await get<CollectiveState>(KEY_PREFIX + collectiveId)
}
const pendingSaves = new Map<Id, ReturnType<typeof setTimeout>>()
/** Debounced write (500 ms) — every mutation calls this; last write wins. */
export function saveStateDebounced(state: CollectiveState): void {
const id = state.collective.id
const existing = pendingSaves.get(id)
if (existing) clearTimeout(existing)
pendingSaves.set(
id,
setTimeout(() => {
pendingSaves.delete(id)
void set(KEY_PREFIX + id, toRaw(state))
}, 500),
)
}
export async function saveStateNow(state: CollectiveState): Promise<void> {
const id = state.collective.id
const existing = pendingSaves.get(id)
if (existing) {
clearTimeout(existing)
pendingSaves.delete(id)
}
await set(KEY_PREFIX + id, toRaw(state))
}
export async function deleteCollective(collectiveId: Id): Promise<void> {
await del(KEY_PREFIX + collectiveId)
const active = await getActiveCollectiveId()
if (active === collectiveId) await set(ACTIVE_KEY, null)
}
export async function getActiveCollectiveId(): Promise<Id | null> {
return (await get<Id | null>(ACTIVE_KEY)) ?? null
}
export async function setActiveCollectiveId(id: Id | null): Promise<void> {
await set(ACTIVE_KEY, id)
}
// ── Export / import — the single bundle path ─────────────────
export function toBundle(state: CollectiveState, exportedAt: ISODate): Bundle {
return { schemaVersion: 2, exportedAt, ...toRaw(state) }
}
export async function sha256Hex(text: string): Promise<string> {
const data = new TextEncoder().encode(text)
const digest = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
export interface ImportIssue {
level: 'error' | 'warning'
message: string
}
/** Structural validation — never throws, returns readable French issues. */
export function validateBundle(raw: unknown): { bundle?: Bundle; issues: ImportIssue[] } {
const issues: ImportIssue[] = []
if (typeof raw !== 'object' || raw === null) {
return { issues: [{ level: 'error', message: 'Ce fichier ne contient pas un collectif lisible.' }] }
}
const b = raw as Partial<Bundle>
if (b.schemaVersion !== 2)
issues.push({ level: 'error', message: 'Version de fichier inconnue (schemaVersion ≠ 2).' })
if (!b.collective?.id || !b.collective?.slug || !b.collective?.name)
issues.push({ level: 'error', message: 'Le collectif du fichier est incomplet (id, slug ou nom manquant).' })
for (const key of [
'people', 'circles', 'decisions', 'concerns', 'objections', 'advices', 'assents',
'mandates', 'docs', 'clauses', 'versions', 'protocols', 'sessions', 'votes',
] as const) {
if (!Array.isArray(b[key]))
issues.push({ level: 'error', message: `Collection manquante ou invalide : ${key}.` })
}
// Invariant: every collective carries a Consent protocol (import validates it).
if (Array.isArray(b.protocols) && !b.protocols.some(p => p.method === 'consent'))
issues.push({
level: 'warning',
message: 'Aucun protocole de consentement — les chemins collectifs retomberont sur « décider sur avis » jusqu\'à sa création.',
})
if (issues.some(i => i.level === 'error')) return { issues }
return { bundle: b as Bundle, issues }
}
export interface ImportResult {
state?: CollectiveState
issues: ImportIssue[]
collided: boolean
}
/**
* Import a bundle as a new local collective.
* Foreign bundles (exportedAt present, not one of ours) get a lineage stamp — « essaimé de … ».
*/
export async function importBundle(json: string, opts?: { asSeed?: boolean }): Promise<ImportResult> {
let raw: unknown
try {
raw = JSON.parse(json)
} catch {
return { issues: [{ level: 'error', message: 'Fichier illisible : ce n\'est pas du JSON valide.' }], collided: false }
}
const { bundle, issues } = validateBundle(raw)
if (!bundle) return { issues, collided: false }
const existingIds = await listCollectiveIds()
const collided = existingIds.includes(bundle.collective.id)
const { schemaVersion: _v, exportedAt, ...stateRest } = bundle
const state: CollectiveState = stateRest
if (!opts?.asSeed) {
state.collective = {
...state.collective,
lineage: {
sourceSlug: bundle.collective.slug,
exportedAt,
sha256: await sha256Hex(json),
},
}
}
return { state, issues, collided }
}
/** Deep-clone to plain JSON — strips Pinia/Vue reactivity proxies before IndexedDB. */
function toRaw<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}