v3 : l'entrée — accueil avec le choix du groupe, logo « Clé de voûte », quatre ambiances, menu et pages d'outil par groupe

- Accueil : le titre, le leitmotiv en trois lignes (contexte et cadrage ·
  processus et modalités · boîte à outils), puis directement le choix du
  groupe ; sans session, la connexion à sa place. /groupes redirige vers /,
  « Mes groupes » et le retour après connexion mènent à /.
- Logo « Clé de voûte » du DA (signe en currentColor, clé en --cle, libre
  Literata italique, Decision romain), droit, dans le menu et la barre
  mobile ; Stamp retiré, le sceau 井 ne bouge pas. Favicon, icônes 16/32/48,
  apple-touch, 192/512, manifeste.
- Quatre ambiances : pierre et calcaire (claires), nuit et ardoise
  (sombres), --cle par ambiance ; quatre pastilles à la place du soleil /
  lune ; première visite selon l'appareil (pierre / nuit). Calcaire et
  ardoise : textes, accent, mousse et avertissement à 4,5:1 au moins.
- Page d'un groupe : les quatre piliers, puis « En cours ».
- Menu : nombre de docs en cours à côté de « Docs de référence », surligné
  seulement dedans, seul Docs réagit au survol ; les trois autres éteints
  mais ouvrent leur page ; hors d'un groupe, le dernier groupe ouvert.
- /g/[group]/docs « En cours » + « À venir » ; /mandats et /protocoles du
  groupe : « À venir » éteints ; /g/[group]/decisions : une ligne par doc,
  « N modules à voter », « dont N sans ton vote ». Anciennes adresses
  /decisions, /mandats, /protocoles : vers le groupe courant, sinon /.
- e2e : C1, C2, C11, C12, M1 mis à jour ; C13–C17 pour L1–L6 ; CONTROLES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 11:21:57 +01:00
co-authored by Claude Opus 5.5
parent c640413a70
commit 81b604e5f2
48 changed files with 1323 additions and 613 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
// The documents in progress of a group, each a way into its reader.
import type { GroupView } from '$lib/group';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { LangMap } from '$lib/types';
let { group, docs, summary = true }: { group: string; docs: GroupView['docs']; summary?: boolean } = $props();
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
{#if docs.length}
<ul class="docs">
{#each docs as d (d.id)}
<li>
<a class="doc card" href="/g/{group}/docs/{d.slug}">
<span class="title">{pick(d.title)}</span>
{#if summary}<span class="summary">{pick(d.summary)}</span>{/if}
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="muted">{t('group.current.none')}</p>
{/if}
<style>
.docs {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
}
.doc {
display: grid;
gap: 0.45rem;
padding: 1.35rem 1.5rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.doc:hover {
transform: translateY(-3px);
}
.title {
font-family: var(--font-text);
font-size: 1.4rem;
font-weight: 500;
}
.summary {
color: var(--ink-soft);
line-height: 1.55;
}
.meta {
font-size: 0.85rem;
font-weight: 700;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
// The choice of a group: Monnaie libre first and large, then the groups that use the same
// key, then the test groups; a group one does not open shows its lock.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import type { GroupSummary } from '$lib/types';
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
onMount(() => void data.load());
const all = $derived(data.value?.groups ?? []);
const root = $derived(all.find((g) => g.slug === 'monnaie-libre'));
const sameKey = $derived(all.filter((g) => g.parent === 'monnaie-libre'));
const others = $derived(all.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
</script>
<section class="choice" aria-labelledby="groups-title">
<h2 class="eyebrow" id="groups-title">{t('groups.title')}</h2>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{#if root}
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
<span class="eyebrow">Ğ1</span>
<span class="name">{root.name}</span>
{#if root.open}<span class="go" aria-hidden="true"><Icon name="chevron" size={20} /></span>{/if}
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
</a>
{/if}
{#if sameKey.length}
<h3 class="eyebrow sub">{t('groups.same.key')}</h3>
<ul class="list">
{#each sameKey as g (g.slug)}
<li>
{#if g.open}
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
{:else}
<div class="row card locked" title={t('groups.locked.long')}>
<span class="name">{g.name}</span>
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
</div>
{/if}
</li>
{/each}
</ul>
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
{/if}
{#if others.length}
<h3 class="eyebrow sub">{t('groups.test')}</h3>
<ul class="list">
{#each others as g (g.slug)}
<li>
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
</a>
</li>
{/each}
</ul>
{/if}
{#if !all.length}<p class="muted">{t('groups.empty')}</p>{/if}
{/if}
</section>
<style>
.choice {
display: grid;
gap: 1rem;
}
.choice > .eyebrow {
color: var(--ink-soft);
}
.root {
position: relative;
display: grid;
gap: 0.3rem;
padding: 1.6rem 1.6rem 1.7rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
a.root[href]:hover,
a.row[href]:hover {
transform: translateY(-3px);
}
.root .name {
font-family: var(--font-text);
font-size: clamp(1.7rem, 1.4rem + 1.2vw, 2.2rem);
font-weight: 500;
}
.go {
position: absolute;
right: 1.4rem;
top: 50%;
translate: 0 -50%;
color: var(--water);
}
.sub {
margin-top: 1rem;
color: var(--ink-soft);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
color: var(--ink);
text-decoration: none;
transition: transform 0.12s ease;
}
.row .name {
font-weight: 750;
}
.locked {
background: transparent;
box-shadow: inset 0 0 0 1px var(--surface-2);
color: var(--ink-faint);
}
.lock {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.82rem;
font-weight: 700;
}
.note {
font-size: 0.88rem;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
// The logotype « Clé de voûte » (DA, validated by Yvv on 2026-10-28): the sign — stones in
// the ink of the mood, the keystone in `--cle` — then *libre* in Literata italic, the colour
// of the stones, and **Decision** in roman, the colour of the keystone. Never tilted.
// Proportions of the DA's lockup (prod_cle.py), for a font size S: the stones run from 0.98 S
// above the baseline to 0.06 S below it, then a gap of 0.3 S. The SVG keeps the DA's 3-unit
// margin around 59 units of stone height, hence the sizes below.
import sign from '$lib/assets/cle-de-voute.svg?raw';
let { size = 1.45 }: { size?: number } = $props();
</script>
<span class="logo" style:--s="{size}rem">
<span class="sign" aria-hidden="true">{@html sign}</span><span class="word"><span class="libre">libre</span><span class="decision">Decision</span></span>
</span>
<style>
.logo {
display: inline-block;
font-size: var(--s);
line-height: 1.2;
white-space: nowrap;
}
.sign {
display: inline-block;
color: var(--ink);
/* 1.04 S of stones, plus the 3-unit margin above and below (65 / 59); the bottom of the
stones 0.06 S under the baseline. */
height: calc(1.04em * 65 / 59);
margin-right: calc(0.3em - 1.04em * 3 / 59);
vertical-align: calc(-0.06em - 1.04em * 3 / 59);
}
.sign :global(svg) {
display: block;
height: 100%;
width: auto;
}
.word {
font-family: var(--font-text);
font-variation-settings: 'opsz' 72;
}
.libre {
font-style: italic;
font-weight: 420;
letter-spacing: -0.004em;
color: var(--ink);
}
.decision {
font-weight: 620;
letter-spacing: -0.006em;
margin-left: 0.02em;
color: var(--cle);
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
// The four pillars of a group: name, one sentence, « ouvert » or « à venir ». Each leads to its
// tool; the open one answers the pointer, the others stay dim, like in the menu.
import { TOOLS, type Tool } from '$lib/group';
import { t, type Key } from '$lib/i18n/index.svelte';
let { group, tools }: { group: string; tools: { key: string; active: boolean }[] } = $props();
const NAMES: Record<Tool, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const TEXTS: Record<Tool, Key> = {
decisions: 'pillar.decisions',
docs: 'pillar.docs',
mandats: 'pillar.mandates',
protocoles: 'pillar.protocols',
};
const active = $derived(new Set(tools.filter((x) => x.active).map((x) => x.key)));
</script>
<ul class="pillars">
{#each TOOLS as tool (tool)}
{@const open = active.has(tool)}
<li>
<a class="pillar" class:open href="/g/{group}/{tool}">
<span class="head">
<h3>{t(NAMES[tool])}</h3>
<span class="tag" class:water={open}>{open ? t('pillar.open') : t('pillar.soon')}</span>
</span>
<span class="text">{t(TEXTS[tool])}</span>
</a>
</li>
{/each}
</ul>
<style>
.pillars {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--surface-2);
border-radius: var(--r-card);
overflow: hidden;
box-shadow: var(--shadow);
}
li {
display: grid;
}
.pillar {
display: grid;
gap: 0.55rem;
align-content: start;
padding: 1.4rem 1.5rem 1.5rem;
background: var(--surface);
color: var(--ink-faint);
text-decoration: none;
transition: background-color 0.15s ease;
}
.pillar.open {
color: var(--ink-soft);
}
.pillar.open:hover {
background: color-mix(in srgb, var(--water) 5%, var(--surface));
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
h3 {
font-size: 1.08rem;
font-weight: 800;
color: var(--ink-soft);
}
.open h3 {
color: var(--ink);
}
.text {
font-size: 0.96rem;
line-height: 1.55;
}
@media (max-width: 640px) {
.pillars {
grid-template-columns: 1fr;
}
}
</style>
+112 -79
View File
@@ -1,61 +1,70 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { mood } from '$lib/mood.svelte';
import { TOOLS, type Tool } from '$lib/group';
import { groups, toolHref } from '$lib/groups.svelte';
import { i18n, LANGS, t, type Key } from '$lib/i18n/index.svelte';
import { MOODS, mood } from '$lib/mood.svelte';
import { session } from '$lib/session.svelte';
import Icon from './Icon.svelte';
import Stamp from './Stamp.svelte';
import Logo from './Logo.svelte';
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
const group = $derived(page.params.group ?? null);
const docsHref = $derived(
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes',
);
const LABELS: Record<Tool, Key> = {
decisions: 'nav.decisions',
docs: 'nav.docs',
mandats: 'nav.mandates',
protocoles: 'nav.protocols',
};
const path = $derived(page.url.pathname);
const tools = $derived([
{ key: 'decisions', label: t('nav.decisions'), href: null },
{ key: 'docs', label: t('nav.docs'), href: docsHref },
{ key: 'mandats', label: t('nav.mandates'), href: null },
{ key: 'protocoles', label: t('nav.protocols'), href: null },
]);
/** The group of the page, if any. */
const here = $derived(page.params.group ?? null);
/** Whose tools the menu shows: the group of the page, or else the last one opened. */
const target = $derived(here ?? (session.me ? groups.last : null));
$effect(() => {
if (session.me && target) groups.ensure(target);
});
const view = $derived(target ? groups.views[target] : undefined);
/** Documents in progress in that group, next to « Docs de référence ». */
const count = $derived(view?.group.open ? view.docs.length : null);
/** A tool is highlighted only when one is in it. */
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
async function signOut() {
await session.logout();
groups.reset();
onnavigate?.();
await goto('/');
}
</script>
<nav class="sidebar" aria-label={t('nav.label')}>
<a class="brand" href="/" onclick={() => onnavigate?.()}>
<Stamp />
<span class="word">libre<b>Decision</b></span>
</a>
<a class="brand" href="/" onclick={() => onnavigate?.()}><Logo /></a>
{#if session.me}
<a class="groups" href="/groupes" class:current={path === '/groupes'} onclick={() => onnavigate?.()}>
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
<span class="eyebrow">{t('nav.groups')}</span>
</a>
{/if}
<ul class="tools">
{#each tools as tool (tool.key)}
{#each TOOLS as tool (tool)}
{@const on = inTool(tool)}
<li>
{#if tool.href}
<a
href={tool.href}
class="tool"
class:current={path.includes('/docs')}
aria-current={path.includes('/docs') ? 'page' : undefined}
onclick={() => onnavigate?.()}>{tool.label}</a
>
{:else}
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}>
{tool.label}<span class="soon">{t('nav.soon')}</span>
</span>
{/if}
<a
href={toolHref(tool, target)}
class="tool {tool}"
class:off={tool !== 'docs'}
class:current={on}
aria-current={on ? 'page' : undefined}
onclick={() => onnavigate?.()}
>
<span>{t(LABELS[tool])}</span>
{#if tool === 'docs' && count !== null}
<span class="count" aria-hidden="true">{count}</span><span class="visually-hidden">{i18n.plural('nav.docs.count', count)}</span>
{/if}
</a>
</li>
{/each}
</ul>
@@ -76,15 +85,21 @@
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
{/each}
</div>
<button
class="icon-btn"
type="button"
onclick={() => mood.toggle()}
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
>
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} />
</button>
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
{#each MOODS as m (m)}
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
<button
type="button"
role="radio"
class="swatch"
data-mood={m}
aria-checked={mood.current === m}
aria-label={t(`mood.${m}`)}
title={t(`mood.${m}`)}
onclick={() => mood.set(m)}
></button>
{/each}
</div>
</div>
{#if session.me}
@@ -109,17 +124,8 @@
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
color: var(--ink);
text-decoration: none;
padding: 0.2rem 0.4rem;
}
.word {
font-size: 1.08rem;
letter-spacing: 0.01em;
}
.word b {
font-weight: 800;
text-decoration: none;
}
.groups {
padding: 0 0.6rem;
@@ -135,35 +141,47 @@
display: grid;
gap: 0.15rem;
}
/* The four tools look alike; only « Docs de référence » is lit and answers the pointer.
The three others stay dim, without any hover effect, and still lead to their page. */
.tool {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
min-height: 2.5rem;
padding: 0.5rem 0.7rem;
border-radius: var(--r-input);
color: var(--ink);
font-weight: 700;
text-decoration: none;
transition: background-color 0.15s ease;
transition: background-color 0.15s ease, color 0.15s ease;
}
a.tool:hover {
.tool.off {
color: var(--ink-faint);
}
.tool.docs:hover,
.tool.current {
background: var(--water-wash);
}
a.tool.current {
background: var(--water-wash);
.tool.docs.current {
color: var(--water);
}
.tool.disabled {
color: var(--ink-faint);
font-weight: 600;
cursor: default;
.tool.off.current {
color: var(--ink-soft);
}
.soon {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--ink-faint);
.count {
min-width: 1.6rem;
padding: 0.05rem 0.45rem;
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--ink-soft);
font-size: 0.78rem;
font-weight: 800;
text-align: center;
}
.tool.current .count {
background: var(--surface);
color: var(--water);
}
.foot {
margin-top: auto;
@@ -197,6 +215,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.2rem 0.3rem;
}
.langs {
@@ -209,7 +228,7 @@
border: 0;
background: transparent;
border-radius: var(--r-pill);
min-width: 2.25rem;
min-width: 2.05rem;
min-height: 2rem;
font-size: 0.78rem;
font-weight: 800;
@@ -220,19 +239,33 @@
color: var(--water);
box-shadow: var(--shadow);
}
.icon-btn {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: var(--r-icon);
background: transparent;
color: var(--ink-soft);
.moods {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.icon-btn:hover {
background: var(--water-wash);
color: var(--ink);
.swatch {
position: relative;
width: 1.3rem;
height: 1.3rem;
padding: 0;
border: 0;
border-radius: 50%;
/* var(--bg), var(--ink) and var(--cle) are those of the swatch's own mood. */
background: var(--bg);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent);
}
.swatch::after {
content: '';
position: absolute;
inset: 32%;
border-radius: 50%;
background: var(--cle);
}
.swatch[aria-checked='true'] {
/* currentColor is the ink of the page, inherited. */
outline: 2px solid currentColor;
outline-offset: 2px;
}
.who {
display: flex;
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
// « À venir »: what is announced in a tool of the group — dim, and not a way in yet.
import type { UpcomingItem } from '$lib/group';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { LangMap } from '$lib/types';
let { items }: { items: UpcomingItem[] } = $props();
const id = $props.id();
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
</script>
<section class="soon" aria-labelledby="{id}-soon">
<h2 class="eyebrow" id="{id}-soon">{t('group.upcoming')}</h2>
{#if items.length}
<ul>
{#each items as item (item.key)}
<li>{pick(item.title)}</li>
{/each}
</ul>
{:else}
<p class="faint">{t('group.upcoming.none')}</p>
{/if}
</section>
<style>
.soon {
display: grid;
gap: 0.8rem;
}
h2 {
color: var(--ink-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
li {
padding: 1.05rem 1.5rem;
border-radius: var(--r-card);
background: color-mix(in srgb, var(--surface) 45%, transparent);
color: var(--ink-faint);
font-family: var(--font-text);
font-size: 1.2rem;
cursor: default;
}
</style>
-35
View File
@@ -1,35 +0,0 @@
<script lang="ts">
// The logo stamp: the six lines of hexagram 48 (The Well), small, tilted.
let { size = 2.2 }: { size?: number } = $props();
const lines: boolean[] = [false, true, true, false, true, false];
</script>
<span class="stamp" style:--s="{size}rem" aria-hidden="true">
<svg viewBox="0 0 112 105">
{#each lines as yang, i (i)}
{@const y = 91 - i * 17}
{#if yang}<rect x="6" {y} width="100" height="9" />{:else}<rect x="6" {y} width="42" height="9" /><rect x="64" {y} width="42" height="9" />{/if}
{/each}
</svg>
</span>
<style>
.stamp {
display: grid;
place-items: center;
flex: none;
width: var(--s);
height: var(--s);
border-radius: var(--r-icon);
background: var(--water-wash);
transform: rotate(-10deg);
transition: transform 0.2s var(--ease);
}
svg {
width: 56%;
fill: var(--water);
}
:global(a:hover) > .stamp {
transform: rotate(-2deg) scale(1.06);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
// The page of a tool not open yet in a group (Mandats, Protocoles): what it is for, and what is
// announced there, dim.
import { page } from '$app/state';
import Guard from '$lib/components/Guard.svelte';
import SoonList from '$lib/components/SoonList.svelte';
import { groups } from '$lib/groups.svelte';
import { t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
let { tool }: { tool: 'mandats' | 'protocoles' } = $props();
const NAMES: Record<typeof tool, Key> = { mandats: 'nav.mandates', protocoles: 'nav.protocols' };
const TEXTS: Record<typeof tool, Key> = { mandats: 'pillar.mandates', protocoles: 'pillar.protocols' };
const slug = $derived(page.params.group ?? '');
const data = new Remote(() => groups.load(slug));
$effect(() => {
void slug;
void data.load();
});
</script>
{#if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
{@const g = data.value.group}
<div class="page">
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
<h1>{t(NAMES[tool])}</h1>
{#if !g.open}
<p class="muted">{t('groups.locked.long')}</p>
{:else}
<p class="lead">{t(TEXTS[tool])}</p>
<SoonList items={data.value.upcoming[tool]} />
{/if}
</div>
{/if}
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
display: grid;
gap: 0.8rem;
}
.back {
text-decoration: none;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
color: var(--ink-soft);
}
.lead {
color: var(--ink-faint);
max-width: 44ch;
line-height: 1.55;
margin-bottom: 1.8rem;
}
</style>
+17
View File
@@ -1,7 +1,24 @@
import type { LangMap } from './types';
/** The tools of a group, in the order of the menu. */
export const TOOLS = ['decisions', 'docs', 'mandats', 'protocoles'] as const;
export type Tool = (typeof TOOLS)[number];
/** Announced in a tool, not open yet (`seeds/upcoming/<group>.json` on the server). */
export interface UpcomingItem {
key: string;
title: LangMap;
}
export interface GroupView {
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
tools: { key: string; active: boolean }[];
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[];
upcoming: { docs: UpcomingItem[]; mandats: UpcomingItem[]; protocoles: UpcomingItem[] };
}
/** « Décisions »: one line per document in progress. */
export interface DecisionsView {
canVote: boolean;
docs: { id: string; slug: string; title: LangMap; toVote: number; withoutMyVote: number | null }[];
}
+93
View File
@@ -0,0 +1,93 @@
// The groups as the menu and the pages share them: the last group opened on this
// device (outside a group, the tools of the menu aim at it), and the views
// already read (the menu counts the documents of the current group).
import { api, ApiError } from './api';
import type { GroupView, Tool } from './group';
const KEY = 'ld:group';
/** The last group opened, read straight from storage (usable before any mount). */
export function lastGroup(): string | null {
try {
return localStorage.getItem(KEY);
} catch {
return null;
}
}
/** Where a tool leads: its page in the group, or home when no group is known. */
export function toolHref(tool: Tool, group: string | null): string {
return group ? `/g/${group}/${tool}` : '/';
}
class Groups {
last = $state<string | null>(null);
views = $state<Record<string, GroupView>>({});
private asked = new Set<string>();
private running = new Map<string, Promise<GroupView>>();
init() {
this.last = lastGroup();
}
/** Read a group (a read already under way is shared); one that opens becomes the last group. */
load(slug: string): Promise<GroupView> {
const running = this.running.get(slug);
if (running) return running;
this.asked.add(slug);
const p: Promise<GroupView> = this.fetch(slug).finally(() => {
if (this.running.get(slug) === p) this.running.delete(slug);
});
this.running.set(slug, p);
return p;
}
private async fetch(slug: string): Promise<GroupView> {
try {
const v = await api.get<GroupView>(`/groups/${slug}`);
this.views[slug] = v;
if (v.group.open) this.remember(slug);
else if (this.last === slug) this.forget();
return v;
} catch (e) {
// A group that no longer shows or opens is no longer the last group; a network hiccup keeps it.
if (this.last === slug && e instanceof ApiError && (e.code === 'not_found' || e.code === 'forbidden')) this.forget();
throw e;
}
}
/** Read a group once, for the menu; errors stay silent there. */
ensure(slug: string) {
if (this.views[slug] || this.asked.has(slug)) return;
this.load(slug).catch(() => {});
}
remember(slug: string) {
this.last = slug;
try {
localStorage.setItem(KEY, slug);
} catch {
/* no storage: the menu simply leads home */
}
}
/** On sign-out, or when the last group no longer opens. */
forget() {
this.last = null;
try {
localStorage.removeItem(KEY);
} catch {
/* no storage */
}
}
/** On sign-out: nothing read under the previous identity is kept. */
reset() {
this.forget();
this.views = {};
this.asked.clear();
this.running.clear();
}
}
export const groups = new Groups();
+27 -15
View File
@@ -10,30 +10,29 @@ export const en: Messages = {
'nav.docs': 'Reference documents',
'nav.mandates': 'Mandates',
'nav.protocols': 'Protocols',
'nav.soon': 'coming soon',
'nav.groups': 'My groups',
'nav.docs.count.one': '{n} document in progress',
'nav.docs.count.other': '{n} documents in progress',
'nav.access': 'Access',
'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Give feedback',
'nav.signin': 'Sign in',
'nav.signout': 'Sign out',
'nav.mood.toNight': 'Night mood',
'nav.mood.toStone': 'Stone mood',
'nav.mood': 'Mood',
'mood.pierre': 'Stone',
'mood.calcaire': 'Limestone',
'mood.nuit': 'Night',
'mood.ardoise': 'Slate',
'nav.lang': 'Language',
'nav.menu': 'Menu',
'nav.close': 'Close',
'home.title': 'Making collective decisions possible',
'home.subtitle': 'depending on the context, processes and methods, a toolbox',
'home.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
'home.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
'home.mandates': 'Create and revoke any kind of mandate.',
'home.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
'home.open': 'open',
'home.soon': 'coming soon',
'home.leitmotiv.context': 'context and framing',
'home.leitmotiv.process': 'processes and methods',
'home.leitmotiv.tools': 'a toolbox',
'home.signin': 'Sign in with your Ğ1 key',
'home.signin.note': 'The same access as the Monnaie libre forum: you sign with Ğecko, no password needed.',
'home.continue': 'Open my groups',
'signin.title': 'Sign in',
'signin.g1': 'Sign in with Ğ1',
@@ -77,8 +76,23 @@ export const en: Messages = {
'groups.empty': 'No group is visible yet.',
'group.tools': 'Tools',
'group.tool.soon': 'This tool is coming later.',
'group.docs.empty': 'No reference documents in this group yet.',
'group.current': 'In progress',
'group.current.none': 'No document in progress in this group yet.',
'group.upcoming': 'Coming up',
'group.upcoming.none': 'Nothing is announced here yet.',
'pillar.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
'pillar.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
'pillar.mandates': 'Create and revoke any kind of mandate.',
'pillar.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
'pillar.open': 'open',
'pillar.soon': 'coming soon',
'decisions.note': 'A module is an article of the document. It remains to be voted on until one of its versions has been adopted by a vote.',
'decisions.toVote.one': '{n} module to vote on',
'decisions.toVote.other': '{n} modules to vote on',
'decisions.withoutMyVote.one': 'of which {n} without your vote',
'decisions.withoutMyVote.other': 'of which {n} without your vote',
'docs.title': 'Reference documents',
'docs.inForce.one': '{n} article in force',
@@ -331,8 +345,6 @@ export const en: Messages = {
'access.publication.queued': 'Publication started again.',
'access.denied': 'Access management is for trustinWallet administrators only.',
'soon.title': 'Coming soon',
'soon.body': 'This tool is coming later. Tools open one at a time: reference documents first.',
'error.unauthenticated': 'Sign in to continue.',
'error.forbidden': "You don't have access to this page.",
+27 -15
View File
@@ -10,30 +10,29 @@ export const es: Messages = {
'nav.docs': 'Documentos de referencia',
'nav.mandates': 'Mandatos',
'nav.protocols': 'Protocolos',
'nav.soon': 'próximamente',
'nav.groups': 'Mis grupos',
'nav.docs.count.one': '{n} documento en curso',
'nav.docs.count.other': '{n} documentos en curso',
'nav.access': 'Accesos',
'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Dejar un comentario',
'nav.signin': 'Iniciar sesión',
'nav.signout': 'Cerrar sesión',
'nav.mood.toNight': 'Ambiente noche',
'nav.mood.toStone': 'Ambiente piedra',
'nav.mood': 'Ambiente',
'mood.pierre': 'Piedra',
'mood.calcaire': 'Caliza',
'mood.nuit': 'Noche',
'mood.ardoise': 'Pizarra',
'nav.lang': 'Idioma',
'nav.menu': 'Menú',
'nav.close': 'Cerrar',
'home.title': 'Hacer posible la decisión colectiva',
'home.subtitle': 'según el contexto, procesos y modalidades, caja de herramientas',
'home.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
'home.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
'home.mandates': 'Crear y revocar todo tipo de mandato.',
'home.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
'home.open': 'abierto',
'home.soon': 'próximamente',
'home.leitmotiv.context': 'contexto y encuadre',
'home.leitmotiv.process': 'procesos y modalidades',
'home.leitmotiv.tools': 'caja de herramientas',
'home.signin': 'Iniciar sesión con tu clave Ğ1',
'home.signin.note': 'El mismo acceso que el foro Monnaie libre: firmas en Ğecko, sin contraseña.',
'home.continue': 'Abrir mis grupos',
'signin.title': 'Iniciar sesión',
'signin.g1': 'Iniciar sesión con Ğ1',
@@ -77,8 +76,23 @@ export const es: Messages = {
'groups.empty': 'Por ahora no hay ningún grupo visible.',
'group.tools': 'Herramientas',
'group.tool.soon': 'Esta herramienta llegará más adelante.',
'group.docs.empty': 'Por ahora no hay ningún documento de referencia en este grupo.',
'group.current': 'En curso',
'group.current.none': 'Por ahora no hay ningún documento en curso en este grupo.',
'group.upcoming': 'Próximamente',
'group.upcoming.none': 'Por ahora no hay nada anunciado aquí.',
'pillar.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
'pillar.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
'pillar.mandates': 'Crear y revocar todo tipo de mandato.',
'pillar.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
'pillar.open': 'abierto',
'pillar.soon': 'próximamente',
'decisions.note': 'Un módulo es un artículo del documento. Queda por votar mientras ninguna de sus versiones haya sido adoptada por votación.',
'decisions.toVote.one': '{n} módulo por votar',
'decisions.toVote.other': '{n} módulos por votar',
'decisions.withoutMyVote.one': 'de ellos, {n} sin tu voto',
'decisions.withoutMyVote.other': 'de ellos, {n} sin tu voto',
'docs.title': 'Documentos de referencia',
'docs.inForce.one': '{n} artículo en vigor',
@@ -331,8 +345,6 @@ export const es: Messages = {
'access.publication.queued': 'Publicación relanzada.',
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
'soon.title': 'Próximamente',
'soon.body': 'Esta herramienta llegará más adelante. Las herramientas se abren una a una: primero, los documentos de referencia.',
'error.unauthenticated': 'Inicia sesión para continuar.',
'error.forbidden': 'No tienes acceso a esta página.',
+27 -15
View File
@@ -13,30 +13,29 @@ export const fr = {
'nav.docs': 'Docs de référence',
'nav.mandates': 'Mandats',
'nav.protocols': 'Protocoles',
'nav.soon': 'à venir',
'nav.groups': 'Mes groupes',
'nav.docs.count.one': '{n} doc en cours',
'nav.docs.count.other': '{n} docs en cours',
'nav.access': 'Accès',
'nav.access.hint': 'trustinWallet',
'nav.feedback': 'Faire un retour',
'nav.signin': 'Se connecter',
'nav.signout': 'Se déconnecter',
'nav.mood.toNight': 'Ambiance nuit',
'nav.mood.toStone': 'Ambiance pierre',
'nav.mood': 'Ambiance',
'mood.pierre': 'Pierre',
'mood.calcaire': 'Calcaire',
'mood.nuit': 'Nuit',
'mood.ardoise': 'Ardoise',
'nav.lang': 'Langue',
'nav.menu': 'Menu',
'nav.close': 'Fermer',
'home.title': 'Rendre possible la décision collective',
'home.subtitle': 'en fonction du contexte, processus et modalités, boîte à outils',
'home.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
'home.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
'home.mandates': 'Créer et révoquer tout type de mandat.',
'home.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
'home.open': 'ouvert',
'home.soon': 'à venir',
'home.leitmotiv.context': 'contexte et cadrage',
'home.leitmotiv.process': 'processus et modalités',
'home.leitmotiv.tools': 'boîte à outils',
'home.signin': 'Se connecter avec sa clé Ğ1',
'home.signin.note': 'Le même accès que le forum Monnaie libre : tu signes dans Ğecko, sans mot de passe.',
'home.continue': 'Ouvrir mes groupes',
'signin.title': 'Se connecter',
'signin.g1': 'Se connecter avec Ğ1',
@@ -80,8 +79,23 @@ export const fr = {
'groups.empty': "Aucun groupe n'est visible pour l'instant.",
'group.tools': 'Outils',
'group.tool.soon': 'Cet outil arrive plus tard.',
'group.docs.empty': "Aucun document de référence dans ce groupe pour l'instant.",
'group.current': 'En cours',
'group.current.none': "Aucun doc en cours dans ce groupe pour l'instant.",
'group.upcoming': 'À venir',
'group.upcoming.none': "Rien n'est annoncé ici pour l'instant.",
'pillar.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
'pillar.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
'pillar.mandates': 'Créer et révoquer tout type de mandat.',
'pillar.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
'pillar.open': 'ouvert',
'pillar.soon': 'à venir',
'decisions.note': "Un module est un article du doc. Il reste à voter tant qu'aucune de ses versions n'a été adoptée par un vote.",
'decisions.toVote.one': '{n} module à voter',
'decisions.toVote.other': '{n} modules à voter',
'decisions.withoutMyVote.one': 'dont {n} sans ton vote',
'decisions.withoutMyVote.other': 'dont {n} sans ton vote',
'docs.title': 'Docs de référence',
'docs.inForce.one': '{n} article en vigueur',
@@ -334,8 +348,6 @@ export const fr = {
'access.publication.queued': 'Publication relancée.',
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
'soon.title': 'À venir',
'soon.body': "Cet outil arrive plus tard. Les chantiers s'ouvrent un par un : d'abord les docs de référence.",
'error.unauthenticated': 'Connecte-toi pour continuer.',
'error.forbidden': "Tu n'as pas accès à cette page.",
+15 -7
View File
@@ -1,19 +1,27 @@
// Two moods, light and dark: « pierre » (stone) and « nuit » (night).
export type Mood = 'pierre' | 'nuit';
// Four moods, two light and two dark: « pierre » and « nuit » (the water),
// « calcaire » and « ardoise » (the ochre). Set before the first paint by
// app.html; a first visit follows the device's light or dark setting.
export const MOODS = ['pierre', 'calcaire', 'nuit', 'ardoise'] as const;
export type Mood = (typeof MOODS)[number];
const isMood = (m: string | undefined): m is Mood => MOODS.includes(m as Mood);
class MoodState {
current = $state<Mood>('pierre');
init() {
const m = document.documentElement.dataset.mood;
this.current = m === 'nuit' ? 'nuit' : 'pierre';
this.current = isMood(m) ? m : 'pierre';
}
toggle() {
this.current = this.current === 'nuit' ? 'pierre' : 'nuit';
document.documentElement.dataset.mood = this.current;
set(m: Mood) {
this.current = m;
const root = document.documentElement;
root.dataset.mood = m;
const bg = getComputedStyle(root).getPropertyValue('--bg').trim();
if (bg) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
try {
localStorage.setItem('ld:mood', this.current);
localStorage.setItem('ld:mood', m);
} catch {
/* private mode: the mood just won't persist */
}