- 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>
578 lines
19 KiB
TypeScript
578 lines
19 KiB
TypeScript
/**
|
||
* 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–Équilibre–Liaison, 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: [],
|
||
}
|
||
}
|