docs: plan d'implémentation Read More

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vincent BourdonandClaude Opus 5.5 committed 2026-11-06 18:48:10 +01:00
1 parent 4cf2ab3b08
commit bb8d03eaab
1 file changed
+153
@@ -0,0 +1,153 @@
# Read More — plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extension FreshRSS qui replie les articles du mode lecture à une hauteur fixe avec un bouton « Lire plus », sans casser le marquage « lu » au scroll.
**Architecture:** Extension `user` sans base de données. `extension.php` injecte un JS et un CSS et passe la hauteur configurée au JS via le hook `js_vars` (`context.extensions.read_more`). Le JS replie les `.flux .text` trop hauts par `max-height` (jamais `display:none`). L'article reste plus haut que 0, donc `onScroll()` de FreshRSS continue de le marquer lu. Le dépôt est directement le dossier de l'extension.
**Tech Stack:** PHP 8.2+ (API Minz de FreshRSS 1.30.4), JS vanilla (pas de build), CSS. Tests : `node:test` + `jsdom` (unitaires DOM), Playwright + Docker Compose (`freshrss/freshrss:1.30.4`) pour le bout-en-bout.
**Spec:** `docs/superpowers/specs/2026-11-06-read-more-design.md`
## Global Constraints
- Cible : **FreshRSS 1.30.4**. L'image Docker de test est épinglée sur `freshrss/freshrss:1.30.4`.
- Mode lecture uniquement : le JS ne fait rien si `context.current_view !== 'reader'`.
- Ne jamais rendre un `.flux` de hauteur 0. Repli = `max-height` + `overflow: hidden` sur `.text`.
- Hauteur repliée par défaut **250 px**, réglable, bornée **100–2000 px** (valeur hors bornes ramenée à la borne).
- Seuil : repli seulement si hauteur du contenu **≥ 1,5 × hauteur repliée**.
- Le bouton ne modifie jamais l'état lu/non lu. Clic : `preventDefault()` + `stopPropagation()`.
- Après dépliage : reste déplié, pas de bouton « Réduire ».
- Libellés : FR « Lire plus », EN « Read more ».
- Aucune dépendance runtime. `node_modules`, `package.json` et `tests/` ne servent qu'au développement.
- Noms fixés : `metadata.json` `name` = `"Read More"`, `entrypoint` = `"ReadMore"`, classe `ReadMoreExtension`, clé de config utilisateur `collapsed_height`.
## Review Focus
1. **Image chargée après coup** : un article d'abord trop court pour être replié et qui grandit quand ses images arrivent doit être replié. Test : `reevaluates short entry after image load` (Task 2).
2. **`freshrss:load-more` émis plusieurs fois** : pas de bouton en double, pas de re-repli d'un article déplié. Tests : `is idempotent` et `does not re-collapse expanded entry` (Task 2).
3. **Valeur de config absurde** (vide, 5, 99999, texte) : bornée, pas d'erreur. Test e2e `config height is clamped` (Task 3).
4. **Extension active hors mode lecture** (vue normale) : aucun bouton, aucun repli. Test e2e `normal view untouched` (Task 3).
5. **Article déplié puis dépassé** : marqué lu une fois son bas dépassé. Test e2e `expanded entry marked read after scrolling past` (Task 3).
---
### Task 1: Squelette de l'extension (PHP, config, i18n, README)
**Files:**
- Create: `metadata.json`, `extension.php`, `configure.phtml`, `i18n/fr/ext.php`, `i18n/en/ext.php`, `README.md`, `LICENSE`, `.gitignore`
- Create (vides pour l'instant, remplis en Task 2) : `static/read-more.js`, `static/read-more.css`
**Interfaces:**
- Produces (pour le JS, Task 2) : `window.context.extensions.read_more = { collapsed_height: int, label: string }`.
- Produces : clé i18n `ext.read_more.button`, `ext.read_more.height.label`, `ext.read_more.height.help`.
- [ ] **Step 1: `metadata.json`**
`{"name": "Read More", "author": "vincentb99", "description": "Replie les articles du mode lecture avec un bouton « Lire plus »", "version": "0.1.0", "entrypoint": "ReadMore", "type": "user"}`
- [ ] **Step 2: `extension.php`** : `final class ReadMoreExtension extends Minz_Extension`, sur le modèle de `xExtension-ReadingTime` (dépôt FreshRSS/Extensions).
- `public const DEFAULT_HEIGHT = 250; MIN_HEIGHT = 100; MAX_HEIGHT = 2000;`
- `init(): void` : `registerTranslates()`. Si `!FreshRSS_Context::hasUserConf()`, arrêter là. Sinon `Minz_View::appendStyle($this->getFileUrl('read-more.css'))`, `Minz_View::appendScript($this->getFileUrl('read-more.js'))`, `registerHook(Minz_HookType::JsVars, $this->jsVars(...))`.
- `public function getCollapsedHeight(): int` : `getUserConfigurationInt('collapsed_height') ?? DEFAULT_HEIGHT`, passé par `clampHeight`.
- `public static function clampHeight(int $h): int` : `max(MIN, min(MAX, $h))`.
- `public function jsVars(array $vars): array` : ajoute `$vars['read_more'] = ['collapsed_height' => getCollapsedHeight(), 'label' => _t('ext.read_more.button')]`.
- `handleConfigureAction(): void` : `registerTranslates()`. En POST, `setUserConfigurationValue('collapsed_height', self::clampHeight(Minz_Request::paramInt('collapsed_height') ?: self::DEFAULT_HEIGHT))`.
- [ ] **Step 3: `configure.phtml`** : même structure que celle de ReadingTime (form POST vers `_url('extension','configure','e',urlencode($this->getName()))`, champ `_csrf`, `form-group`, boutons `gen.action.submit` / `gen.action.cancel`). Un champ `<input type="number" name="collapsed_height" min="100" max="2000" step="10">` avec pour valeur `getCollapsedHeight()`.
- [ ] **Step 4: i18n** : `i18n/fr/ext.php` et `i18n/en/ext.php` retournent `['read_more' => ['button' => …, 'height' => ['label' => …, 'help' => …]]]`.
FR : « Lire plus », « Hauteur repliée (px) », « Hauteur visible d'un article avant le bouton « Lire plus » (100 à 2000). »
EN : "Read more", "Collapsed height (px)", "Visible height of an article before the "Read more" button (100–2000)."
- [ ] **Step 5: README.md** (FR) : rôle de l'extension, prérequis (FreshRSS ≥ 1.30.4, option « marquer comme lu au défilement » activée), installation Docker (`git clone` dans `/var/www/FreshRSS/extensions/`, activation dans Configuration → Extensions), mise à jour (`git pull`), réglage, lancement des tests. `LICENSE` : MIT, copyright vincentb99. `.gitignore` : `node_modules/`, `test-results/`, `playwright-report/`.
- [ ] **Step 6: Lint PHP**
Run: `docker run --rm -v "$PWD":/app -w /app php:8.2-cli sh -c 'for f in extension.php configure.phtml i18n/*/ext.php; do php -l "$f" || exit 1; done'`
Expected : `No syntax errors detected` pour chaque fichier.
- [ ] **Step 7: Commit** : `git add -A && git commit -m "feat: squelette de l'extension Read More (config, i18n)"`
---
### Task 2: Logique de repli (JS + CSS) et tests unitaires
**Files:**
- Create: `package.json` (`"private": true`, `"type": "commonjs"`, devDeps `jsdom`, `@playwright/test`, scripts `"test:unit": "node --test tests/unit/"`, `"test:e2e": "playwright test"`)
- Modify: `static/read-more.js`, `static/read-more.css`
- Test: `tests/unit/read-more.test.js`
**Interfaces:**
- Consumes : `window.context.extensions.read_more` (Task 1), `window.context.current_view`.
- Produces : `static/read-more.js` est un IIFE qui expose `window.ReadMore` et, si `module` existe, `module.exports`, avec :
- `THRESHOLD_RATIO = 1.5`
- `shouldCollapse(contentHeight: number, collapsedHeight: number): boolean`
- `processEntries(root: ParentNode, opts: { collapsedHeight: number, label: string, measure?: (el: Element) => number }): void`. `measure` vaut par défaut `el => el.scrollHeight`.
- `init(win: Window): void` : vérifie la vue, lit la config, lance `processEntries(document)`, écoute `freshrss:load-more` sur `document.body` et les `load` d'images en capture sur `document`. S'exécute au chargement si `window.context.extensions` existe, sinon sur `freshrss:globalContextLoaded`.
- Conventions DOM (utilisées aussi par Task 3) :
- sur `.flux` : `data-read-more` = `"collapsed"` | `"short"` | `"expanded"`
- sur `.text` replié : classe `read-more-collapsed`, style inline `--read-more-height: <n>px`
- bouton : `<button type="button" class="btn read-more-btn">label</button>` inséré juste après `.text`
- [ ] **Step 1: Écrire les tests** dans `tests/unit/read-more.test.js` (jsdom, fixture : deux `.flux` contenant `article.flux_content > div.content > div.text`, `measure` injecté par une map élément → hauteur) :
- `shouldCollapse thresholds` : `(374, 250) → false`, `(375, 250) → true`, `(1000, 250) → true`
- `collapses tall entry` : hauteur 1000, h=250 → `.text` a `read-more-collapsed` et la propriété `--read-more-height` vaut `250px`. Un seul `.read-more-btn`, de texte `Lire plus`, frère suivant de `.text`. `data-read-more="collapsed"`.
- `skips short entry` : hauteur 300 → pas de classe, pas de bouton, `data-read-more="short"`
- `is idempotent` : `processEntries` appelé deux fois → toujours un seul bouton par article
- `click expands` : clic → classe retirée, bouton retiré, `data-read-more="expanded"`, `event.defaultPrevented === true`. Un listener `click` sur le `.flux` parent n'est pas appelé.
- `does not re-collapse expanded entry` : clic, puis `processEntries` à nouveau → toujours déplié, pas de bouton
- `does not touch read state` : un `.flux.not_read` reste `not_read` après repli et clic
- `reevaluates short entry after image load` : article `short`, sa hauteur passe à 1000, puis dispatch `new Event('load')` sur un `<img>` de son `.text` (après `init`) → replié
- `init ignores non-reader view` : `context.current_view = 'normal'` → aucun bouton
- `init handles load-more` : `current_view='reader'`, `init`, puis ajout d'un nouveau `.flux` haut et dispatch de `freshrss:load-more` sur `body` → nouveau bouton
- [ ] **Step 2: Vérifier l'échec** : `npm install && npm run test:unit` → FAIL (fonctions absentes).
- [ ] **Step 3: Implémenter `static/read-more.js`** selon les interfaces ci-dessus. Ne traiter que les `.flux` sans `data-read-more`, ou ceux en `short` lors d'une réévaluation. Pour `init` en jsdom, laisser `measure` injectable via `win.ReadMore._measure` ou un paramètre optionnel, au choix de l'implémenteur, pourvu que les tests l'utilisent.
- [ ] **Step 4: `static/read-more.css`**
- `.flux .text.read-more-collapsed { max-height: var(--read-more-height, 250px); overflow: hidden; position: relative; }`
- `::after` en bas : dégradé de `transparent` vers la couleur de fond du thème (`var(--frss-background-color, #fff)`), hauteur 4em, `pointer-events: none`
- `.read-more-btn { display: block; margin: 0.5em auto 1em; }`
- [ ] **Step 5: Tests verts** : `npm run test:unit` → tous PASS.
- [ ] **Step 6: Commit** : `git add -A && git commit -m "feat: repli des articles en mode lecture avec bouton Lire plus"`
---
### Task 3: Tests bout-en-bout sur FreshRSS 1.30.4
**Files:**
- Create: `tests/e2e/docker-compose.yml`, `tests/e2e/fixtures/feed.xml`, `tests/e2e/fixtures/subscriptions.opml`, `tests/e2e/setup.sh`, `tests/e2e/read-more.spec.js`, `playwright.config.js`
**Interfaces:**
- Consumes : conventions DOM de Task 2, nom `"Read More"` et clé `collapsed_height` de Task 1.
- [ ] **Step 1: Environnement**
- `docker-compose.yml` : service `freshrss` (`freshrss/freshrss:1.30.4`, port `8080:80`, volume `../..:/var/www/FreshRSS/extensions/freshrss-read-more:ro`, `FRESHRSS_INSTALL: --default-user test --auth-type none --db-type sqlite --base-url http://localhost:8080`, `FRESHRSS_USER: --user test --password testtest1`). Service `feeds` (`nginx:alpine`) qui sert `fixtures/` sur le réseau compose.
- `feed.xml` : RSS avec 8 items. Les items 1 à 6 sont longs (≥ 15 paragraphes), le 7 est court (1 paragraphe) et le 8 est long.
- `subscriptions.opml` : un abonnement `http://feeds/feed.xml`.
- `setup.sh` : `docker compose up -d --wait`, puis dans le conteneur (`--user www-data`) : `cli/import-for-user.php --user test --filename …opml`, `cli/actualize-user.php --user test`. Enfin un `php -r` qui modifie `data/users/test/config.php` (`include`, puis `var_export`) : `extensions_enabled['Read More']=true`, `mark_when['scroll']=true`, `view_mode='reader'`. Ces clés sont des tableaux, que `reconfigure-user.php` refuse en 1.30.4. Le script se termine par `docker compose down -v` s'il est appelé avec `--down`.
- [ ] **Step 2: Écrire `tests/e2e/read-more.spec.js`** (viewport 390×844, `baseURL http://localhost:8080`, page `/i/?a=reader&state=2`, c'est-à-dire les non lus). Avant chaque test, remettre tout en non lu et `collapsed_height` à 250 via `php -r` dans le conteneur :
- `long entries collapsed` : chaque article long a `data-read-more="collapsed"` et un `.read-more-btn`. La hauteur de son `.text` est ≤ 250.
- `short entry not collapsed` : l'item 7 est en `data-read-more="short"`, sans bouton
- `collapsed entry marked read after scrolling past` : scroller jusqu'à ce que le bas du 1er `.flux` dépasse le haut de l'écran, attendre 1 s (throttle de 500 ms) → le 1er `.flux` n'a plus `not_read`. Après rechargement de la page des non lus, l'article n'y figure plus.
- `read more expands without changing read state or scroll` : clic sur le bouton du 1er article → `.text` sans `max-height`, l'article est toujours `not_read`, et `window.scrollY` varie de moins de 5 px
- `expanded entry marked read after scrolling past` : déplier le 2e, scroller au-delà de son bas → lu
- `normal view untouched` : `/i/?a=normal` → 0 `.read-more-btn`
- `config height is clamped` : sur la page de config de l'extension, soumettre 5 → le champ affiche 100. Soumettre 99999 → 2000. En mode lecture avec 400, la hauteur de `.text` replié vaut ≤ 400.
- [ ] **Step 3: Lancer** : `bash tests/e2e/setup.sh && npx playwright install chromium && npm run test:e2e` → tous PASS. En cas d'échec sur le marquage lu, vérifier d'abord que `mark_when.scroll` est bien actif (`cli/reconfigure-user.php --user test --list`) avant de soupçonner l'extension.
- [ ] **Step 4: Commit** : `git add -A && git commit -m "test: e2e sur FreshRSS 1.30.4 (repli, marquage lu au scroll)"`
---
### Task 4: Vérification manuelle et publication
- [ ] **Step 1** : avec l'environnement de Task 3, ouvrir `http://localhost:8080` dans le navigateur en mode mobile. Vérifier visuellement le dégradé et le bouton dans les thèmes clair et sombre (Configuration → Affichage).
- [ ] **Step 2** : `bash tests/e2e/setup.sh --down`.
- [ ] **Step 3** : demander confirmation à l'utilisateur avant `git push -u origin main` (dépôt externe).
- [ ] **Step 4** : installation en production (par l'utilisateur, suivant le README) : `git clone` dans le volume extensions, activation, réglage, test sur smartphone.