Files
decision/frontend/app/data/templates.ts
T
YvvandClaude Fable 5 d886302b59 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>
2026-08-11 10:30:35 +02:00

578 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The seven collective templates (BLUEPRINT-V2.md « Seeds » GABARITS + Δ10/Δ11/Δ20).
*
* buildTemplateBundle() produces a COMPLETE schemaVersion-2 Bundle that goes
* through the SAME import path as a user bundle (importBundle, asSeed:true).
*
* Doctrinal invariants honored here:
* - every template seeds a Consent protocol (protocolByRange.consent — Δ10);
* - every ClauseVersion is born of a Decision: one FOUNDING decision
* (route 'record', adopted) carries every seeded clause version — a single
* shared founding act, exactly what a real constitutive gesture is;
* - blank stays a blank page: preamble + A1 + the « consignation » clause +
* requireEffects='none' + the Consent protocol ONLY — every other setting
* falls back to SETTINGS_DEFAULTS through resolveSettings;
* - the five standard templates (informal/association/cooperative/commune/
* free-currency) seed the 10 triage setting clauses + Consent, Nuancé,
* Réglage collectif, Élection — 'protocols.large' = the NUANCED protocol
* everywhere EXCEPT free-currency, where large = the inertial binary
* (D30M50B.1G.2 — the Ğ1 heritage put back in its place);
* - free-currency: the default resources unit is 'DU' — v2 keeps it as a
* DESCRIPTIVE default suggested by the tunnel UI (Resources.unit), never a
* conversion: DU and € are oil and water, never summed (huile/eau);
* - symmetric: full transparency (isTransparent), a « Déclaration de
* symétrie » reference TextDoc, and the draft decision « Déclarer notre
* symétrie ». Two MandateDrafts are SUGGESTED by the onboarding UI (not
* seeded — a mandate is born of a decision, never of a template):
* { title: 'Ambassadeur', domainCircleIds: [root], domainTags: ['liaison'],
* durationDays: 180, reportEveryDays: 30 }
* { title: 'Négociateur', domainCircleIds: [root], domainTags: ['symétrie'],
* durationDays: 180, reportEveryDays: 30 }
* - every template proposes the first decision « Adopter notre Pacte »
* (route 'collective', status 'draft', consent protocol).
*/
import type {
Bundle,
Circle,
Clause,
ClauseVersion,
Collective,
CollectiveTemplate,
Decision,
FormulaParams,
Id,
InertiaPreset,
ISODate,
Json,
Person,
Protocol,
TextDoc,
VoteMethod,
} from '../types/domain'
import { formatModeParams } from '../engine/modeParams'
export type TemplateId = CollectiveTemplate
// ─────────────────────────────────────────────────────────────
// The seven cards (/creer, écran 2) — blank FIRST
// ─────────────────────────────────────────────────────────────
export interface TemplateCard {
id: TemplateId
title: string
subtitle: string
description: string
}
const STANDARD_SUBTITLE = 'point de départ — tout est amendable par décision'
export const TEMPLATE_CARDS: TemplateCard[] = [
{
id: 'blank',
title: 'Page blanche',
subtitle: "observatoire d'abord",
description:
"Une clause : nous consignons nos décisions ; le reste s'écrira au fil de l'eau. "
+ 'Consentement seul installé.',
},
{
id: 'informal',
title: 'Informel',
subtitle: STANDARD_SUBTITLE,
description:
"Un groupe d'amis, un projet naissant : des chemins légers, aucune matière exigée.",
},
{
id: 'association',
title: 'Association',
subtitle: STANDARD_SUBTITLE,
description:
'Un Bureau, le consentement et le vote nuancé — la vie associative sans les camps.',
},
{
id: 'cooperative',
title: 'Coopérative',
subtitle: STANDARD_SUBTITLE,
description:
'Décider ensemble du travail et des ressources — matière exigée sur ce qui engage.',
},
{
id: 'commune',
title: 'Commune',
subtitle: STANDARD_SUBTITLE,
description:
'Des quartiers, des périmètres emboîtés — la subsidiarité en actes.',
},
{
id: 'free-currency',
title: 'Communauté monnaie libre',
subtitle: STANDARD_SUBTITLE,
description:
"L'héritage Toile de Confiance : le pour/contre inertiel, outil de dernier recours "
+ 'des très grands corps.',
},
{
id: 'symmetric',
title: 'Institution symétrique',
subtitle: 'le geste fondateur',
description:
'Transparence totale, Déclaration de symétrie, et la première décision : '
+ 'déclarer notre symétrie.',
},
]
// ─────────────────────────────────────────────────────────────
// buildTemplateBundle
// ─────────────────────────────────────────────────────────────
export interface TemplateBuildOptions {
name: string
slug: string
color: string
icon: string
meName: string
memberNames: string[]
now: ISODate
newId: () => Id
}
/** Which protocols a template installs. */
const STANDARD_TEMPLATES: TemplateId[] = [
'informal',
'association',
'cooperative',
'commune',
'free-currency',
'symmetric',
]
export function buildTemplateBundle(id: TemplateId, opts: TemplateBuildOptions): Bundle {
const { name, slug, color, icon, meName, memberNames, now, newId } = opts
const collectiveId = newId()
const entity = () => ({
id: newId(),
collectiveId,
createdAt: now,
updatedAt: now,
})
// ── People — me + the initial members ──────────────────────
const me: Person = { ...entity(), displayName: meName, isMe: true }
const members: Person[] = memberNames.map(displayName => ({
...entity(),
displayName,
isMe: false,
}))
const people = [me, ...members]
// ── Circles — root « Tous » + one sober typed circle per template ──
const rootCircle: Circle = {
...entity(),
name: 'Tous',
purpose: 'Toutes les personnes du collectif',
memberIds: people.map(p => p.id),
domains: [],
}
const circles: Circle[] = [rootCircle]
const typed: Partial<Record<TemplateId, { name: string; purpose: string; kind: Circle['kind'] }>> = {
association: { name: 'Bureau', purpose: "L'équipe qui fait tourner l'association", kind: 'team' },
cooperative: { name: 'Conseil', purpose: 'Le conseil de la coopérative', kind: 'team' },
commune: { name: 'Quartiers', purpose: 'Les lieux de la commune', kind: 'place' },
'free-currency': { name: 'Forgerons', purpose: 'Celles et ceux qui forgent les blocs', kind: 'team' },
}
const typedDef = typed[id]
if (typedDef) {
circles.push({
...entity(),
name: typedDef.name,
purpose: typedDef.purpose,
kind: typedDef.kind,
memberIds: [],
parentCircleId: rootCircle.id,
domains: [],
})
}
// ── Protocols ──────────────────────────────────────────────
const protocols: Protocol[] = []
const mkProtocol = (
pName: string,
method: VoteMethod,
description: string,
durationDays: number,
formula: FormulaParams,
): Protocol => {
const protocol: Protocol = {
...entity(),
name: pName,
method,
description,
durationDays,
ballot: 'open',
formula,
modeParams: formatModeParams({
duration_days: durationDays,
majority_pct: formula.majorityPct,
base_exponent: formula.baseExponent,
gradient_exponent: formula.gradientExponent,
constant_base: formula.constantBase,
}),
}
protocols.push(protocol)
return protocol
}
const baseFormula = { majorityPct: 50, baseExponent: 0.1, gradientExponent: 0.2, constantBase: 0 }
const consent = mkProtocol(
'Consentement',
'consent',
'Ça me va / J\'objecte — zéro objection maintenue vaut adoption.',
7,
{ ...baseFormula },
)
let nuanced: Protocol | undefined
let parametric: Protocol | undefined
let election: Protocol | undefined
let binary: Protocol | undefined
if (STANDARD_TEMPLATES.includes(id)) {
nuanced = mkProtocol(
'Vote nuancé',
'nuanced',
'Chacun·e se prononce en nuances, pas en camps.',
14,
{ ...baseFormula, nuancedThresholdPct: 60, nuancedMinParticipants: 3 },
)
parametric = mkProtocol(
'Réglage collectif',
'parametric',
'Décider au curseur — le collectif retient la médiane de chaque curseur.',
14,
{ ...baseFormula, parametricMinParticipants: 3 },
)
election = mkProtocol(
'Élection',
'election',
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez.",
14,
{ ...baseFormula, electionMinParticipants: 3, tieBreak: 'runoff' },
)
}
if (id === 'free-currency') {
// D30M50B.1G.2 — the exact inherited inertia of the Ğ1 web of trust.
binary = mkProtocol(
'Vote WoT binaire inertiel',
'binary',
"l'outil de dernier recours des très grands corps — héritage Toile de Confiance",
30,
{ ...baseFormula },
)
}
// ── The Pact + clauses, each founded by ONE adopted decision ──
const pactDoc: TextDoc = {
...entity(),
slug: 'pacte',
title: 'Notre Pacte',
role: 'pact',
description: 'Notre contrat social — sacralisé, jamais immuable',
}
const docs: TextDoc[] = [pactDoc]
const founding: Decision = {
...entity(),
authorId: me.id,
title: 'Fonder notre Pacte',
tags: ['pacte'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'record',
triageRule: 'R0a',
routeOverridden: false,
decidedHow: 'posé à la création du collectif — chaque clause reste amendable',
status: 'adopted',
decidedAt: now,
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
}
const clauses: Clause[] = []
const versions: ClauseVersion[] = []
let position = 0
const mkClause = (
section: string,
code: string,
title: string,
inertia: InertiaPreset,
content: string,
settingKey?: string,
settingValue?: Json,
): Clause => {
const clause: Clause = {
...entity(),
docId: pactDoc.id,
section,
position: position++,
code,
title,
inertia,
...(settingKey !== undefined ? { settingKey } : {}),
}
const version: ClauseVersion = {
...entity(),
clauseId: clause.id,
decisionId: founding.id,
versionLabel: 'v1',
content,
...(settingValue !== undefined ? { settingValue } : {}),
status: 'current',
adoptedAt: now,
}
clause.currentVersionId = version.id
clauses.push(clause)
versions.push(version)
return clause
}
// Preamble P0 — Autonomie–ÉquilibreLiaison, proposed and MODIFIABLE.
mkClause(
'Préambule',
'P0',
'Préambule',
'standard',
'Autonomie — chacun·e décide de ce qui ne concerne que lui ou elle. '
+ 'Équilibre — ce qui engage le collectif se décide ensemble, à la juste échelle. '
+ 'Liaison — chaque décision garde sa trace et reste révisable. '
+ 'Ce préambule est proposé, jamais imposé : modifiable comme tout le reste.',
)
// A1 — Notre finalité (default compasses: vitalité / émancipation).
mkClause(
'Finalité',
'A1',
'Notre finalité',
'high',
"La vitalité du collectif et l'émancipation de chacun·e sont nos deux boussoles.",
)
if (id === 'blank') {
// The one practice clause of the blank page.
mkClause(
'Pratique',
'C1',
'Nous consignons nos décisions',
'standard',
"Nous consignons nos décisions ; le reste s'écrira au fil de l'eau.",
)
}
// ── Setting clauses ────────────────────────────────────────
const requireEffects = id === 'blank' || id === 'informal' ? 'none' : 'binding'
const requireEffectsContent =
requireEffects === 'none'
? "Aucune matière exigée — le collectif s'outillera quand la pratique le demandera."
: 'Matière exigée : décisions engageantes et structurantes — au moins un effet recherché avant tout vote collectif.'
if (id === 'blank') {
// Blank page: requireEffects='none' + the consent protocol, NOTHING else —
// every other setting falls back to SETTINGS_DEFAULTS via resolveSettings.
mkClause('Réglages', 'S1', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', 'none')
mkClause(
'Réglages',
'S2',
'Protocole de consentement',
'high',
'Notre protocole de consentement : Consentement (7 jours).',
'protocols.consent',
consent.id,
)
} else {
// The 10 triage keys of SETTINGS_DEFAULTS, in clear French.
mkClause('Réglages', 'S1', 'Petit groupe', 'standard', "Jusqu'à 5 personnes, un avis suffit avant de décider.", 'triage.smallGroupMax', 5)
mkClause('Réglages', 'S2', 'Grand collectif', 'standard', 'Au-delà de 50 personnes, la modalité des grands corps s\'applique.', 'triage.collectiveMin', 50)
mkClause('Réglages', 'S3', 'Consentement', 'standard', "Jusqu'à 7 personnes, un tour d'accord suffit : sans objection, c'est adopté.", 'triage.consentMax', 7)
mkClause('Réglages', 'S4', "Fenêtre d'objection", 'standard', "Quarante-huit heures pour dire « Ça me va » ou objecter.", 'triage.objectionWindowHours', 48)
mkClause('Réglages', 'S5', "Fenêtre d'avis", 'standard', 'Soixante-douze heures pour déposer un avis.', 'triage.adviceWindowHours', 72)
mkClause('Réglages', 'S6', 'Formulation', 'standard', "Quatorze jours pour s'instruire et formuler des contre-propositions.", 'triage.framingDays', 14)
mkClause('Réglages', 'S7', 'Débordement du périmètre', 'standard', 'À la moitié de déclarations spontanées, on élargit ou on motive publiquement.', 'triage.concernEscalateRatio', 0.5)
mkClause('Réglages', 'S8', 'Récurrence', 'standard', 'À la troisième décision semblable, réclamer un mandat ou créer une règle.', 'triage.recurrenceThreshold', 3)
mkClause('Réglages', 'S9', "Épreuve du réel", 'standard', "Quatre-vingt-dix jours après l'adoption, le réel a-t-il suivi ?", 'triage.reviewDelayDays', 90)
mkClause('Réglages', 'S10', 'Matière exigée', 'standard', requireEffectsContent, 'triage.requireEffects', requireEffects)
// Protocol clauses — the Pact references its protocols (pactClauseId).
const consentClause = mkClause(
'Réglages',
'S11',
'Protocole de consentement',
'high',
'Notre protocole de consentement : Consentement (7 jours).',
'protocols.consent',
consent.id,
)
consent.pactClauseId = consentClause.id
if (nuanced) {
const c = mkClause(
'Réglages',
'S12',
'Protocole nuancé',
'high',
'De 8 à 50 personnes : le vote nuancé, six nuances, jamais deux camps.',
'protocols.nuanced',
nuanced.id,
)
nuanced.pactClauseId = c.id
}
// 'protocols.large' — nuanced everywhere, inertial binary ONLY here.
const large = id === 'free-currency' ? binary : nuanced
if (large) {
const c = mkClause(
'Réglages',
'S13',
'Modalité des grands corps',
'max',
id === 'free-currency'
? 'Au-delà de 50 : le pour/contre inertiel hérité de la Toile de Confiance (D30M50B.1G.2).'
: 'Au-delà de 50 : le vote nuancé, la modalité que notre Pacte a choisie.',
'protocols.large',
large.id,
)
if (id === 'free-currency' && binary) binary.pactClauseId = c.id
}
if (parametric) {
const c = mkClause(
'Réglages',
'S14',
'Réglage collectif',
'high',
'Montant, taux, répartition : décider au curseur — le collectif retient la médiane.',
'protocols.parametric',
parametric.id,
)
parametric.pactClauseId = c.id
}
if (election) {
const c = mkClause(
'Réglages',
'S15',
"Protocole d'élection",
'high',
"Désigner une personne — blanc possible ; en cas d'égalité, vous départagez, jamais l'outil.",
'protocols.election',
election.id,
)
election.pactClauseId = c.id
}
}
if (id === 'symmetric') {
// Full transparency, declared in the Pact itself.
mkClause(
'Symétrie',
'T1',
'Transparence totale',
'max',
'Tout ce que nous décidons est public — la symétrie commence par la transparence.',
)
docs.push({
...entity(),
slug: 'declaration-de-symetrie',
title: 'Déclaration de symétrie',
role: 'reference',
description:
'Le geste fondateur : notre institution se déclare symétrique de celles qui nous gouvernent.',
})
}
// ── Decisions — founding (adopted) + first proposals (draft) ──
const decisions: Decision[] = [founding]
decisions.push({
...entity(),
authorId: me.id,
title: 'Adopter notre Pacte',
body: 'Relire le Pacte proposé par le gabarit, l\'amender si besoin, et l\'adopter ensemble.',
tags: ['pacte'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'collective',
triageRule: 'R5',
routeOverridden: false,
protocolId: consent.id,
status: 'draft',
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
})
if (id === 'symmetric') {
decisions.push({
...entity(),
authorId: me.id,
title: 'Déclarer notre symétrie',
body: 'Adopter la Déclaration de symétrie et la rendre publique.',
tags: ['symétrie'],
reversibility: 'costly',
weight: 'structural',
urgent: false,
scope: { selfOnly: false, circleIds: [rootCircle.id], personIds: [] },
route: 'collective',
triageRule: 'R5',
routeOverridden: false,
protocolId: consent.id,
status: 'draft',
stewardIds: [me.id],
measurerIds: [],
visibility: 'collective',
})
}
// ── The tenant ─────────────────────────────────────────────
const collective: Collective = {
id: collectiveId,
slug,
name,
color,
icon,
template: id,
isTransparent: id === 'symmetric' || id === 'free-currency',
pactDocId: pactDoc.id,
rootCircleId: rootCircle.id,
createdAt: now,
updatedAt: now,
}
return {
schemaVersion: 2,
exportedAt: now,
collective,
people,
circles,
decisions,
concerns: [],
objections: [],
advices: [],
assents: [],
mandates: [],
docs,
clauses,
versions,
protocols,
sessions: [],
votes: [],
}
}