Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Reportés : file d’attente des fusions hors connexion (supprimée, ADR 0041),
| Ricochets, PWA : moins de touchers ([ADR 0068](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0068-ricochets-moins-de-touchers-pour-decouper-et-assembler.md)) : la lame de découpe arrive, et repart après chaque coupe, sur une jointure tirée au hasard qui n’est jamais une coupe attendue ; *Assembler* envoie la réponse à la seconde brique posée, sans « Valider » ; tests du composant `Assembler` | fait |
| Ricochets, PWA : *Paires* envoie les réponses à la dernière paire reliée, sans « Valider les paires », comme *Découper* et *Assembler* ; la place du numéro de paire est réservée, les colonnes ne se redimensionnent plus à chaque paire reliée ; tests du composant `Paires` | fait |
| « En savoir plus » (I-16) : chemin des langues, relations, transformations, certitude et sources citées ; dépliée depuis la carte du codex (`plus=1`) | fait (Content publie les types de relation et de transformation dans les référentiels) |
| Vue Réseau (App#91) : la constellation en deux rives (1ʳᵉ case à gauche, 2ᵉ à droite, une bande par fascicule débloqué, ordre alphabétique, rangées de 44 px), dernière découverte en Corail, barre de famille Rosée, antennes chiffrées dans la vue « Tous », famille suivie dans un panneau collé, liste en repli ; calculée côté client (`services/constellation.ts`), sans légendaire ni fascicule verrouillé | fait en local ; ADR et maquette à écrire dans Design |

### Exigence reportée à J6

Expand Down
248 changes: 124 additions & 124 deletions web/package-lock.json

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions web/src/codex.css
Original file line number Diff line number Diff line change
Expand Up @@ -571,3 +571,74 @@
.cb-fasc-btn.is-locked .jk-mini { opacity: 0.45; }
.cb-fasc-verrou { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; line-height: 1.3; }
.cb-fasc-verrou .ic { flex: none; width: 16px; height: 16px; }

/* ---------- Codex : vue Réseau, la constellation en deux rives (App#91) ---------- */
.cst { display: grid; gap: 12px; min-width: 0; }
.cst-choix { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0 24px; min-width: 0; }
.cst-choix .cb-views { margin-bottom: 0; }
.cst-vues { flex: 1 1 260px; min-width: 0; }
.cst .cb-note { margin: 0; }
.cst-mot { font-family: var(--serif); }
/* Cadre à l'échelle 1 : il défile en hauteur avec la page, en largeur dans son propre cadre. */
.cst-cadre { min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; border: 1.5px solid var(--line); border-radius: 14px; background: var(--paper); }
.cst-svg { display: block; }
.cst-filet { fill: none; stroke: var(--line-strong); stroke-opacity: 0.35; stroke-width: 1; }
.cst-titre { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-weight: 700; }
/* Courbes : neutres ; la dernière découverte en Corail (nouveauté) ; la famille suivie en Encre. */
.cst-lien { fill: none; stroke: var(--ink-3); stroke-width: 1; }
.cst-lien.is-dernier { stroke: var(--c-action); stroke-width: 2.5; }
.cst-antenne { fill: none; stroke: var(--ink-3); stroke-width: 1.5; stroke-linecap: round; }
.cst-compte { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; }
.has-focus :is(.cst-lien, .cst-antenne) { stroke: var(--line); }
.has-focus .cst-compte { opacity: 0.45; }
.has-focus .cst-lien.is-famille { stroke: var(--ink); stroke-width: 2.5; }
/* Briques : toute la rangée est la cible. */
.cst-n { cursor: pointer; outline: none; }
.cst-cible { fill: transparent; stroke: none; }
.cst-n:hover .cst-cible { fill: color-mix(in srgb, var(--ink) 6%, transparent); }
.cst-n:focus-visible .cst-cible { stroke: var(--ink); stroke-width: 2; }
.cst-ombre { fill: var(--pop-shadow); }
.cst-brique { fill: var(--c-prefix); stroke: var(--fixed-ink); stroke-width: 1.5; }
.cst-brique[data-kind="suffixe"] { fill: var(--c-suffix); }
/* Cartes de mot neutres : Encre sur Crème, de jour comme de nuit (comme .brick). */
.cst-brique[data-kind="mot"] { fill: #fffbf3; }
.cst-etiquette { fill: var(--fixed-ink); font-family: var(--serif); font-weight: 700; pointer-events: none; }
.cst-rail { fill: var(--paper-2); stroke: var(--ink-3); stroke-width: 0.75; }
.cst-part { fill: var(--c-family); }
.cst-cpt { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; pointer-events: none; }
.cst-n.is-focus .cst-brique { stroke-width: 3; }
.cst-n.is-focus .cst-cible { fill: color-mix(in srgb, var(--ink) 8%, transparent); }
/* Hors famille : la brique pâlit, son étiquette reste lisible (Encre fixe sur couleur vive). */
.cst-n.is-loin .cst-brique { fill-opacity: 0.45; stroke-opacity: 0.45; }
.cst-n.is-loin :is(.cst-ombre, .cst-rail, .cst-part) { opacity: 0.45; }
.cst-n.is-loin .cst-cpt { fill: var(--ink-3); }
/* Panneau de la famille suivie : collé en bas de l'écran, repliable. */
.cst-panneau { position: sticky; bottom: 12px; z-index: 2; display: grid; gap: 8px; padding: 6px 10px; border: 1.5px solid var(--line-strong); border-radius: 14px; background: var(--cream); box-shadow: 4px 4px 0 var(--pop-shadow); }
.cst-tete { display: flex; align-items: center; gap: 8px; min-width: 0; }
.cst-deplier { display: flex; flex: 1 1 auto; align-items: center; gap: 8px; min-width: 0; min-height: 48px; padding: 4px 6px; border: 0; border-radius: 10px; background: transparent; color: var(--ink); cursor: pointer; font: inherit; font-size: 14px; text-align: left; }
.cst-deplier:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
.cst-deplier:focus-visible, .cst-partenaire:focus-visible, .cst-lien-mot:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }
.cst-resume-fam { display: flex; flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 0 8px; min-width: 0; }
.cst-glose { font-family: var(--serif); }
.cst-nb { color: var(--ink-2); font-family: var(--mono); font-size: 12px; }
.cst-chevron { flex: none; width: 8px; height: 8px; margin: 0 6px; border-right: 2px solid var(--ink-3); border-bottom: 2px solid var(--ink-3); transform: rotate(45deg); transition: transform var(--dur-rapide) var(--ease-out); }
.cst-deplier[aria-expanded="true"] .cst-chevron { transform: rotate(-135deg); }
.cst-fermer { flex: none; min-height: 44px; padding: 6px 12px; }
.cst-mots { display: flex; flex-wrap: wrap; gap: 0 14px; max-height: 38vh; margin: 0; padding: 0 0 4px; overflow-y: auto; list-style: none; }
.cst-mots li { display: inline-flex; align-items: center; gap: 2px; }
.cst-lien-mot { min-height: 44px; padding: 0 4px; border: 0; border-radius: 8px; background: transparent; color: var(--ink); cursor: pointer; font-family: var(--serif); font-size: 15px; font-style: italic; text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; }
.cst-partenaire { display: inline-grid; place-items: center; min-width: 44px; min-height: 44px; padding: 0 6px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; }
.cst-partenaire:hover .bchip { box-shadow: 3px 3px 0 var(--pop-shadow); }
/* Liste en repli, qui sert aussi aux lecteurs d'écran. */
.cst-liste { display: grid; gap: 18px; }
.cst-bande h3 { margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1.5px solid var(--line); font-family: var(--mono); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; }
.cst-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px 24px; }
.cst-familles { display: grid; align-content: start; gap: 10px; margin: 0; padding: 0; list-style: none; }
.cst-fam-tete { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.cst-barre { display: inline-block; width: 72px; height: 7px; overflow: hidden; border: 1px solid var(--ink-3); border-radius: 999px; background: var(--paper-2); }
.cst-barre i { display: block; height: 100%; background: var(--c-family); }
.cst-num { font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; }
.cst-mots-liste { margin: 0; color: var(--ink-2); }
.cst-mots-liste .cst-lien-mot { min-height: 32px; font-size: 14px; }
:root[data-mouvement="reduit"] .cst-chevron { transition: none; }
@media (prefers-reduced-motion: reduce) { .cst-chevron { transition: none; } }
17 changes: 14 additions & 3 deletions web/src/composants/Codex.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, cloneElement, createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Fragment, cloneElement, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'

import {
CERTITUDES,
Expand Down Expand Up @@ -30,6 +30,8 @@ import { bandeau } from './bandeau'
import { FormeRarete } from './Rarete'
import { Filet, Galet } from './Maitrise'
import { Jaquette, SelecteurFascicules, TitreFascicule } from './Fascicules'
import { Constellation } from './Constellation'
import { useDeblocage } from '../services/deblocage'

type Tri = TriCodex
const TRIS: [Tri, string][] = [
Expand All @@ -53,6 +55,7 @@ const VUES: [VueCodex, string][] = [
['tout', 'Tout'],
['recentes', 'Récentes'],
['familles', 'Familles'],
['reseau', 'Réseau'],
['cabinet', 'Cabinet'],
]

Expand Down Expand Up @@ -947,6 +950,12 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang
const lesFamilles = familles(entrees, jeu)
const aCompleter = lesFamilles.filter((f) => f.trouves < f.mots.length)
const stats = cabinet(entrees, jeu)
// Vue Réseau : seuls les fascicules débloqués y entrent (ADR 0069) ; sans contexte, tous les parus.
const deblocage = useDeblocage()
const debloques = useMemo(
() => new Set(jeu.fascicules.filter((f) => !deblocage || deblocage.debloques.has(f.manifeste.id)).map((f) => f.numero)),
[jeu, deblocage],
)

// Précédente et suivante : l'ordre de ce qui est affiché.
const sequence = enRecherche
Expand All @@ -955,7 +964,7 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang
? dernieres
: vue.vue === 'familles'
? lesFamilles.flatMap((f) => f.mots)
: vue.vue === 'cabinet'
: vue.vue === 'cabinet' || vue.vue === 'reseau'
? []
: visibles.filter((e) => !replie(e))

Expand Down Expand Up @@ -1024,7 +1033,7 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang
</>
) : (
<>
Vue <em>{{ tout: 'globale', familles: 'des familles', cabinet: 'du cabinet' }[vue.vue]}</em>
Vue <em>{{ tout: 'globale', familles: 'des familles', reseau: 'en réseau', cabinet: 'du cabinet' }[vue.vue]}</em>
</>
)}
</h2>
Expand Down Expand Up @@ -1141,6 +1150,8 @@ export function Codex({ entrees, jeu, joursDeJeu, nouvelles, onVue, vue, onChang
))}
</div>
)
) : vue.vue === 'reseau' ? (
<Constellation jeu={jeu} entrees={entrees} debloques={debloques} onOuvrir={naviguer} />
) : vue.vue === 'cabinet' ? (
<div className="cab">
<section className="cab-stats" aria-labelledby="cab-titre">
Expand Down
47 changes: 47 additions & 0 deletions web/src/composants/Constellation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import type { EntreeCodex } from '../services/codex'
import type { BriqueJeu, CatalogueJeu } from '../services/jeu'
import { Constellation } from './Constellation'

const brique = (id: string, nature: BriqueJeu['nature'], libelle: string): BriqueJeu =>
({ id, uniteId: `u_${id}`, nature, forme: libelle, libelle, rarete: 'commune', glose: 'sens', langue: null, origine: null }) as BriqueJeu
const jeu = {
fascicules: [
{ numero: 1, manifeste: { id: 'fsc_1', nom: 'Rosette' } },
{ numero: 2, manifeste: { id: 'fsc_2', nom: 'Argile' } },
],
briques: new Map([brique('geo', 'prefixe', 'géo-'), brique('bio', 'prefixe', 'bio-'), brique('logie', 'suffixe', '-logie')].map((b) => [b.id, b])),
recettes: [
{ id: 'r1', motId: 'geologie', emplacements: [['geo'], ['logie']] },
{ id: 'r2', motId: 'biologie', emplacements: [['bio'], ['logie']] },
],
} as unknown as CatalogueJeu
const entrees = [
{ id: 'geologie', fascicule: 1, type: 'mot', libelle: 'géologie', trouvee: true, trouveeLe: '2026-10-01T08:00:00Z', legendaire: false },
{ id: 'biologie', fascicule: 1, type: 'mot', libelle: 'biologie', trouvee: false, trouveeLe: null, legendaire: false },
{ id: 'u_geo', fascicule: 1, type: 'prefixe', libelle: 'géo-', trouvee: true, legendaire: false },
{ id: 'u_bio', fascicule: 1, type: 'prefixe', libelle: 'bio-', trouvee: false, legendaire: false },
{ id: 'u_logie', fascicule: 1, type: 'suffixe', libelle: '-logie', trouvee: true, legendaire: false },
] as unknown as EntreeCodex[]

describe('Constellation', () => {
it('ne montre que les briques trouvées des fascicules débloqués, et suit une famille', () => {
render(<Constellation jeu={jeu} entrees={entrees} debloques={new Set([1])} onOuvrir={() => {}} />)
expect(screen.getByRole('button', { name: /^1.·.Rosette$/ })).toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByRole('button', { name: /^2.·.Argile$/ })).not.toBeInTheDocument()
expect(screen.getByText(/1 sur 2\s+mots dans Rosette/)).toBeInTheDocument()
expect(screen.queryByText('bio-')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^-logie/ }))
expect(screen.getByRole('region', { name: 'Famille suivie' })).toHaveTextContent('1 mot formé sur 2')
})

it('offre une liste en repli, qui ouvre la carte d’un mot', () => {
const ouverts: string[] = []
render(<Constellation jeu={jeu} entrees={entrees} debloques={new Set([1])} onOuvrir={(id) => ouverts.push(id)} />)
fireEvent.click(screen.getByRole('button', { name: 'Liste' }))
fireEvent.click(screen.getAllByRole('button', { name: 'géologie' })[0]!)
expect(ouverts).toEqual(['geologie'])
})
})
Loading
Loading