# 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 `` 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: px` - bouton : `` 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 `` 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.