Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
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
.fluxde hauteur 0. Repli =max-height+overflow: hiddensur.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.jsonettests/ne servent qu'au développement. - Noms fixés :
metadata.jsonname="Read More",entrypoint="ReadMore", classeReadMoreExtension, clé de config utilisateurcollapsed_height.
Review Focus
- 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). freshrss:load-moreémis plusieurs fois : pas de bouton en double, pas de re-repli d'un article déplié. Tests :is idempotentetdoes not re-collapse expanded entry(Task 2).- Valeur de config absurde (vide, 5, 99999, texte) : bornée, pas d'erreur. Test e2e
config height is clamped(Task 3). - Extension active hors mode lecture (vue normale) : aucun bouton, aucun repli. Test e2e
normal view untouched(Task 3). - 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 dexExtension-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à. SinonMinz_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é parclampHeight.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, boutonsgen.action.submit/gen.action.cancel). Un champ<input type="number" name="collapsed_height" min="100" max="2000" step="10">avec pour valeurgetCollapsedHeight(). -
Step 4: i18n :
i18n/fr/ext.phpeti18n/en/ext.phpretournent['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 clonedans/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 detectedpour 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", devDepsjsdom,@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.jsest un IIFE qui exposewindow.ReadMoreet, simoduleexiste,module.exports, avec :THRESHOLD_RATIO = 1.5shouldCollapse(contentHeight: number, collapsedHeight: number): booleanprocessEntries(root: ParentNode, opts: { collapsedHeight: number, label: string, measure?: (el: Element) => number }): void.measurevaut par défautel => el.scrollHeight.init(win: Window): void: vérifie la vue, lit la config, lanceprocessEntries(document), écoutefreshrss:load-moresurdocument.bodyet lesloadd'images en capture surdocument. S'exécute au chargement siwindow.context.extensionsexiste, sinon surfreshrss:globalContextLoaded.
-
Conventions DOM (utilisées aussi par Task 3) :
- sur
.flux:data-read-more="collapsed"|"short"|"expanded" - sur
.textreplié : classeread-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
- sur
-
Step 1: Écrire les tests dans
tests/unit/read-more.test.js(jsdom, fixture : deux.fluxcontenantarticle.flux_content > div.content > div.text,measureinjecté par une map élément → hauteur) :shouldCollapse thresholds:(374, 250) → false,(375, 250) → true,(1000, 250) → truecollapses tall entry: hauteur 1000, h=250 →.textaread-more-collapsedet la propriété--read-more-heightvaut250px. Un seul.read-more-btn, de texteLire 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:processEntriesappelé deux fois → toujours un seul bouton par articleclick expands: clic → classe retirée, bouton retiré,data-read-more="expanded",event.defaultPrevented === true. Un listenerclicksur le.fluxparent n'est pas appelé.does not re-collapse expanded entry: clic, puisprocessEntriesà nouveau → toujours déplié, pas de boutondoes not touch read state: un.flux.not_readrestenot_readaprès repli et clicreevaluates short entry after image load: articleshort, sa hauteur passe à 1000, puis dispatchnew Event('load')sur un<img>de son.text(aprèsinit) → repliéinit ignores non-reader view:context.current_view = 'normal'→ aucun boutoninit handles load-more:current_view='reader',init, puis ajout d'un nouveau.fluxhaut et dispatch defreshrss:load-moresurbody→ nouveau bouton
-
Step 2: Vérifier l'échec :
npm install && npm run test:unit→ FAIL (fonctions absentes). -
Step 3: Implémenter
static/read-more.jsselon les interfaces ci-dessus. Ne traiter que les.fluxsansdata-read-more, ou ceux enshortlors d'une réévaluation. Pouriniten jsdom, laissermeasureinjectable viawin.ReadMore._measureou 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; }::afteren bas : dégradé detransparentvers 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_heightde Task 1. -
Step 1: Environnement
docker-compose.yml: servicefreshrss(freshrss/freshrss:1.30.4, port8080: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). Servicefeeds(nginx:alpine) qui sertfixtures/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 abonnementhttp://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 unphp -rqui modifiedata/users/test/config.php(include, puisvar_export) :extensions_enabled['Read More']=true,mark_when['scroll']=true,view_mode='reader'. Ces clés sont des tableaux, quereconfigure-user.phprefuse en 1.30.4. Le script se termine pardocker compose down -vs'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 etcollapsed_heightà 250 viaphp -rdans le conteneur :long entries collapsed: chaque article long adata-read-more="collapsed"et un.read-more-btn. La hauteur de son.textest ≤ 250.short entry not collapsed: l'item 7 est endata-read-more="short", sans boutoncollapsed entry marked read after scrolling past: scroller jusqu'à ce que le bas du 1er.fluxdépasse le haut de l'écran, attendre 1 s (throttle de 500 ms) → le 1er.fluxn'a plusnot_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 →.textsansmax-height, l'article est toujoursnot_read, etwindow.scrollYvarie de moins de 5 pxexpanded entry marked read after scrolling past: déplier le 2e, scroller au-delà de son bas → lunormal view untouched:/i/?a=normal→ 0.read-more-btnconfig 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.textreplié 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 quemark_when.scrollest 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:8080dans 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 clonedans le volume extensions, activation, réglage, test sur smartphone.