devmethod-ai 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/design-to-code/SKILL.md +7 -1
- package/.agents/skills/design-to-code/references/visual-creation.md +37 -0
- package/.agents/skills/project-foundation/SKILL.md +1 -1
- package/.agents/skills/project-foundation/assets/PROJECT_PROFILE.md +1 -0
- package/.agents/skills/project-foundation/references/mission-context.md +10 -0
- package/.agents/skills/project-foundation/references/operating-commands.md +14 -2
- package/.agents/skills/scoped-delivery/SKILL.md +2 -0
- package/.agents/skills/scoped-delivery/assets/MISSION.md +18 -1
- package/COMPATIBILITY.md +1 -1
- package/README.md +31 -12
- package/docs/MISSIONS.md +2 -0
- package/docs/RELEASE-0.1.0.md +14 -3
- package/docs/RELEASE-0.2.0.md +37 -0
- package/docs/ROADMAP.md +2 -0
- package/docs/VISUAL-CREATION-PROPOSAL.md +21 -0
- package/docs/VISUAL-WORKFLOW.md +75 -0
- package/docs/images/devmethod-delivery.svg +10 -0
- package/docs/images/devmethod-flow.svg +13 -26
- package/docs/media/from-zero/README.md +17 -0
- package/docs/media/from-zero/devmethod-demo.fr.srt +59 -0
- package/docs/media/from-zero/scenes.json +50 -0
- package/docs/media/full-chain-4k/README.md +37 -0
- package/docs/media/full-chain-4k/devmethod-chaine-complete.fr.srt +435 -0
- package/docs/media/full-chain-4k/scenes.json +173 -0
- package/docs/media/full-chain-4k/transcripts.fr.md +315 -0
- package/docs/media/visual-chain/README.md +44 -0
- package/docs/media/visual-chain/assets/add-book-v1.png +0 -0
- package/docs/media/visual-chain/assets/completed-v1.png +0 -0
- package/docs/media/visual-chain/devmethod-du-besoin-au-produit.fr.srt +147 -0
- package/docs/media/visual-chain/execution.fr.md +30 -0
- package/docs/media/visual-chain/image-prompts.json +9 -0
- package/docs/media/visual-chain/reference-hashes.json +5 -0
- package/docs/media/visual-chain/scenes.json +232 -0
- package/docs/media/visual-chain/video-preview.jpg +0 -0
- package/docs/missions/visual-workflow.md +46 -0
- package/examples/clair-from-zero/AGENT-EVALUATION.md +15 -0
- package/examples/clair-from-zero/MISSION.md +41 -0
- package/examples/clair-from-zero/README.md +24 -0
- package/examples/clair-from-zero/app/app.mjs +69 -0
- package/examples/clair-from-zero/app/domain.mjs +30 -0
- package/examples/clair-from-zero/app/index.html +20 -0
- package/examples/clair-from-zero/app/storage.mjs +4 -0
- package/examples/clair-from-zero/app/styles.css +128 -0
- package/examples/clair-from-zero/browser-check.cjs +2 -0
- package/examples/clair-from-zero/tests/domain.test.mjs +37 -0
- package/examples/visual-pilot/README.md +11 -0
- package/examples/visual-pilot/app/app.js +10 -0
- package/examples/visual-pilot/app/index.html +1 -0
- package/examples/visual-pilot/app/reference.png +0 -0
- package/examples/visual-pilot/app/style.css +3 -0
- package/examples/visual-pilot/browser-check.cjs +3 -0
- package/examples/visual-pilot/desktop-actual.png +0 -0
- package/examples/visual-pilot/directions-prompt.txt +1 -0
- package/examples/visual-pilot/directions-v1.png +0 -0
- package/examples/visual-pilot/editorial-mockup-prompt.txt +1 -0
- package/examples/visual-pilot/editorial-mockup-v1.png +0 -0
- package/examples/visual-pilot/mismatch-probe.png +0 -0
- package/examples/visual-pilot/mobile-actual.png +0 -0
- package/examples/visual-pilot/quick-filter/AGENT-RESULT.md +25 -0
- package/examples/visual-pilot/quick-filter/baseline.log +42 -0
- package/examples/visual-pilot/quick-filter/filter.mjs +3 -0
- package/examples/visual-pilot/quick-filter/filter.test.mjs +9 -0
- package/package.json +2 -2
- package/scripts/media/demo-actions.cjs +16 -0
- package/scripts/media/encode-demo.py +22 -0
- package/scripts/media/record-demo.cjs +30 -0
- package/scripts/media/visual-short/check.cjs +1 -0
- package/scripts/media/visual-short/encode.py +16 -0
- package/scripts/media/visual-short/record.cjs +4 -0
- package/scripts/media/visual-short/story.py +34 -0
- package/scripts/package-smoke.mjs +2 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {createTask,toggleTask,removeTask,restoreTask,filterTasks} from './domain.mjs';
|
|
2
|
+
import {readTasks,writeTasks} from './storage.mjs';
|
|
3
|
+
const $ = selector => document.querySelector(selector);
|
|
4
|
+
let tasks = [], filter = 'all', removed = null, readBlocked = false;
|
|
5
|
+
const announce = message => { $('#announcement').textContent = message; };
|
|
6
|
+
function showStorageError(message, blocked = false) {
|
|
7
|
+
$('#storage-alert').hidden = false; $('#storage-message').textContent = message;
|
|
8
|
+
$('#retry-storage').hidden = blocked; $('#reset-storage').hidden = !blocked;
|
|
9
|
+
}
|
|
10
|
+
try { tasks = readTasks(window.localStorage); }
|
|
11
|
+
catch { readBlocked = true; showStorageError('Les données enregistrées ne peuvent pas être lues. Vos changements restent uniquement en mémoire. Pour protéger les données existantes, la sauvegarde est suspendue. Vous pouvez les effacer explicitement pour repartir avec cette liste.', true); }
|
|
12
|
+
function save() {
|
|
13
|
+
if (readBlocked) return;
|
|
14
|
+
try { writeTasks(window.localStorage, tasks); $('#storage-alert').hidden = true; }
|
|
15
|
+
catch { showStorageError('La sauvegarde a échoué. Vos changements restent uniquement en mémoire et peuvent être perdus à la fermeture ou au rechargement. Libérez de l’espace ou autorisez le stockage, puis réessayez.'); }
|
|
16
|
+
}
|
|
17
|
+
function commit(message) { save(); render(); announce(message); }
|
|
18
|
+
function setFilter(value) { filter = value; render(); }
|
|
19
|
+
function render() {
|
|
20
|
+
const done = tasks.filter(task => task.done).length;
|
|
21
|
+
$('#total-count').textContent = String(tasks.length).padStart(2,'0');
|
|
22
|
+
$('#all-count').textContent = tasks.length; $('#active-count').textContent = tasks.length-done; $('#done-count').textContent = done;
|
|
23
|
+
$('#progress-label').textContent = tasks.length ? `${done} sur ${tasks.length} terminée${tasks.length > 1 ? 's' : ''}` : 'Un nouveau départ';
|
|
24
|
+
$('#progress-bar').style.width = `${tasks.length ? done/tasks.length*100 : 0}%`;
|
|
25
|
+
document.querySelectorAll('[data-filter]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.filter === filter)));
|
|
26
|
+
const visible = filterTasks(tasks, filter), list = $('#task-list'); list.replaceChildren();
|
|
27
|
+
for (const task of visible) {
|
|
28
|
+
const row = document.createElement('li'); row.className = `task${task.done?' done':''}`; row.dataset.id = task.id;
|
|
29
|
+
const check = document.createElement('button'); check.type = 'button'; check.className = 'task-check'; check.setAttribute('role','checkbox'); check.setAttribute('aria-checked', String(task.done)); check.setAttribute('aria-label', `${task.done?'Marquer à faire':'Terminer'} : ${task.title}`); check.textContent = task.done ? '✓' : '';
|
|
30
|
+
check.addEventListener('click', () => {
|
|
31
|
+
const position = visible.findIndex(item => item.id === task.id);
|
|
32
|
+
tasks = toggleTask(tasks, task.id); commit(task.done ? 'Priorité marquée à faire.' : 'Priorité terminée.');
|
|
33
|
+
const newRows = [...list.children], same = newRows.find(item => item.dataset.id === task.id);
|
|
34
|
+
(same?.querySelector('.task-check') || newRows[Math.min(position,newRows.length-1)]?.querySelector('.task-check') || $('#empty-add')).focus();
|
|
35
|
+
});
|
|
36
|
+
const content = document.createElement('div'), title = document.createElement('h3'); title.textContent = task.title; content.append(title);
|
|
37
|
+
if (task.note) { const note = document.createElement('p'); note.textContent = task.note; content.append(note); }
|
|
38
|
+
if (task.done) { const state = document.createElement('span'); state.className = 'task-state'; state.textContent = 'TERMINÉE'; content.append(state); }
|
|
39
|
+
const del = document.createElement('button'); del.type = 'button'; del.className = 'task-delete'; del.setAttribute('aria-label', `Supprimer : ${task.title}`);
|
|
40
|
+
del.innerHTML = '<svg viewBox="0 0 20 22" aria-hidden="true"><path d="M3 6h14M7 6V3h6v3M5 6l1 14h8l1-14M8 9v8M12 9v8"/></svg>';
|
|
41
|
+
del.addEventListener('click', () => { const result = removeTask(tasks, task.id); tasks = result.tasks; removed = result.removed; commit('Priorité supprimée. Vous pouvez annuler.'); $('#undo-button').focus(); });
|
|
42
|
+
row.append(check,content,del); list.append(row);
|
|
43
|
+
}
|
|
44
|
+
$('#empty-state').hidden = visible.length > 0;
|
|
45
|
+
if (!tasks.length) { $('#empty-title').textContent = 'Tout commence par une chose.'; $('#empty-copy').textContent = 'Qu’aimeriez-vous faire avancer aujourd’hui ? Ajoutez votre première priorité, même petite.'; $('#empty-add').textContent = 'Écrire ma première priorité ↗'; }
|
|
46
|
+
else if (filter === 'done') { $('#empty-title').textContent = 'Chaque chose en son temps.'; $('#empty-copy').textContent = 'Vos priorités terminées apparaîtront ici. Un petit pas suffit pour commencer.'; $('#empty-add').textContent = 'Voir les priorités à faire ↗'; }
|
|
47
|
+
else { $('#empty-title').textContent = 'De la place pour souffler.'; $('#empty-copy').textContent = 'Toutes vos priorités sont terminées. Savourez ce que vous avez fait aujourd’hui.'; $('#empty-add').textContent = 'Ajouter une nouvelle priorité ↗'; }
|
|
48
|
+
$('#undo-bar').hidden = !removed;
|
|
49
|
+
}
|
|
50
|
+
$('#task-form').addEventListener('submit', event => {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
try {
|
|
53
|
+
const task = createTask($('#task-title').value, $('#task-note').value, crypto.randomUUID());
|
|
54
|
+
tasks = [...tasks,task]; filter = 'all'; $('#task-form').reset(); $('#form-error').hidden = true; $('#task-title').removeAttribute('aria-invalid'); commit('Priorité ajoutée.'); $('#task-title').focus();
|
|
55
|
+
} catch (error) { $('#form-error').textContent = error.message; $('#form-error').hidden = false; $('#task-title').setAttribute('aria-invalid','true'); $('#task-title').focus(); }
|
|
56
|
+
});
|
|
57
|
+
$('#task-title').addEventListener('input', () => { if ($('#task-title').value.trim()) { $('#form-error').hidden = true; $('#task-title').removeAttribute('aria-invalid'); } });
|
|
58
|
+
document.querySelectorAll('[data-filter]').forEach(button => button.addEventListener('click', () => { setFilter(button.dataset.filter); announce(`${filterTasks(tasks,filter).length} priorité(s) affichée(s).`); }));
|
|
59
|
+
$('#empty-add').addEventListener('click', () => { if (tasks.length && filter === 'done') { setFilter('active'); $('[data-filter="active"]').focus(); } else { $('#task-title').focus(); $('#task-title').scrollIntoView({block:'center',behavior:'auto'}); } });
|
|
60
|
+
$('#undo-button').addEventListener('click', () => { const id = removed?.task.id; tasks = restoreTask(tasks,removed); removed = null; filter = 'all'; commit('Suppression annulée.'); [...$('#task-list').children].find(row => row.dataset.id === id)?.querySelector('.task-check').focus(); });
|
|
61
|
+
$('#dismiss-undo').addEventListener('click', () => { removed = null; render(); $('#priorities').focus(); });
|
|
62
|
+
$('#demo-button').addEventListener('click', () => {
|
|
63
|
+
const examples = [['Lire quelques pages','Démo fictive · Un chapitre, un thé et le téléphone de côté.'],['Faire une promenade','Démo fictive · Vingt minutes sans itinéraire.'],['Écrire à une personne chère','Démo fictive · Juste quelques mots pour prendre des nouvelles.']];
|
|
64
|
+
tasks = [...tasks,...examples.map(([title,note]) => createTask(title,note,crypto.randomUUID()))]; filter = 'all'; commit('Trois priorités fictives ajoutées à la liste.');
|
|
65
|
+
});
|
|
66
|
+
$('#retry-storage').addEventListener('click', () => { save(); if ($('#storage-alert').hidden) { announce('Liste sauvegardée dans ce navigateur.'); $('#task-title').focus(); } });
|
|
67
|
+
$('#reset-storage').addEventListener('click', () => { readBlocked = false; save(); if ($('#storage-alert').hidden) { announce('Les anciennes données ont été remplacées par la liste actuelle.'); $('#task-title').focus(); } });
|
|
68
|
+
$('#today').textContent = new Intl.DateTimeFormat('fr-FR',{weekday:'long',day:'numeric',month:'long'}).format(new Date()).toLocaleUpperCase('fr-FR');
|
|
69
|
+
render();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const TITLE_LIMIT = 160;
|
|
2
|
+
export const NOTE_LIMIT = 500;
|
|
3
|
+
export function createTask(title, note, id) {
|
|
4
|
+
if (typeof title !== 'string' || !title.trim()) throw new Error('Donnez un titre à votre priorité.');
|
|
5
|
+
if (title.trim().length > TITLE_LIMIT) throw new Error('Le titre est limité à 160 caractères.');
|
|
6
|
+
if (typeof note !== 'string' || note.trim().length > NOTE_LIMIT) throw new Error('La note est limitée à 500 caractères.');
|
|
7
|
+
return { id, title: title.trim(), note: note.trim(), done: false };
|
|
8
|
+
}
|
|
9
|
+
export function toggleTask(tasks, id) { return tasks.map(task => task.id === id ? {...task, done: !task.done} : task); }
|
|
10
|
+
export function removeTask(tasks, id) {
|
|
11
|
+
const index = tasks.findIndex(task => task.id === id);
|
|
12
|
+
return { tasks: tasks.filter(task => task.id !== id), removed: index < 0 ? null : { task: tasks[index], index } };
|
|
13
|
+
}
|
|
14
|
+
export function restoreTask(tasks, removed) {
|
|
15
|
+
if (!removed || tasks.some(task => task.id === removed.task.id)) return tasks;
|
|
16
|
+
const result = [...tasks]; result.splice(Math.min(removed.index, result.length), 0, removed.task); return result;
|
|
17
|
+
}
|
|
18
|
+
export function filterTasks(tasks, filter) { return tasks.filter(task => filter === 'done' ? task.done : filter === 'active' ? !task.done : true); }
|
|
19
|
+
export function decodeTasks(raw) {
|
|
20
|
+
if (raw === null) return [];
|
|
21
|
+
const data = JSON.parse(raw);
|
|
22
|
+
if (!data || data.version !== 1 || !Array.isArray(data.tasks)) throw new Error('Format de données inconnu.');
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
for (const task of data.tasks) {
|
|
25
|
+
if (!task || typeof task.id !== 'string' || !task.id || ids.has(task.id) || typeof task.title !== 'string' || !task.title.trim() || task.title.length > TITLE_LIMIT || typeof task.note !== 'string' || task.note.length > NOTE_LIMIT || typeof task.done !== 'boolean') throw new Error('Données enregistrées illisibles.');
|
|
26
|
+
ids.add(task.id);
|
|
27
|
+
}
|
|
28
|
+
return data.tasks.map(({id, title, note, done}) => ({id, title, note, done}));
|
|
29
|
+
}
|
|
30
|
+
export function encodeTasks(tasks) { return JSON.stringify({version: 1, tasks}); }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#f7f3eb"><title>Clair — Place à l’essentiel</title><link rel="stylesheet" href="styles.css"><script type="module" src="app.mjs"></script></head>
|
|
4
|
+
<body>
|
|
5
|
+
<a class="skip" href="#priorities">Aller aux priorités</a>
|
|
6
|
+
<div class="page">
|
|
7
|
+
<header class="masthead"><a class="brand" href="./" aria-label="Clair, accueil">clair<span class="brand-dot">✳</span></a><span class="masthead-note">UN PEU MOINS. UN PEU MIEUX.</span><span class="edition">VOTRE ESPACE PERSONNEL</span></header>
|
|
8
|
+
<main>
|
|
9
|
+
<section class="hero" aria-labelledby="hero-title"><div><p class="eyebrow" id="today">LE FIL DU JOUR</p><h1 id="hero-title">Faire place<br>à <em>l’essentiel.</em></h1><p class="intro">Une journée, quelques priorités.<br>Gardez de la place pour ce qui compte vraiment.</p></div><div class="hero-aside" aria-hidden="true"><div class="sun"><i></i><i></i><i></i><i></i></div><p>Moins de bruit.<br>Plus de présence.</p><span>À VOTRE RYTHME</span></div></section>
|
|
10
|
+
<div id="storage-alert" class="storage-alert" role="alert" hidden><p id="storage-message"></p><button type="button" id="retry-storage">Réessayer la sauvegarde</button><button type="button" id="reset-storage" hidden>Effacer les données illisibles</button></div>
|
|
11
|
+
<div class="workspace">
|
|
12
|
+
<section class="priorities" id="priorities" aria-labelledby="list-heading" tabindex="-1"><div class="section-heading"><h2 id="list-heading">Vos priorités</h2><span class="count" id="total-count">00</span></div><div class="list-toolbar"><div class="filters" role="group" aria-label="Filtrer les priorités"><button type="button" data-filter="all" aria-pressed="true">Toutes <span id="all-count">0</span></button><button type="button" data-filter="active" aria-pressed="false">À faire <span id="active-count">0</span></button><button type="button" data-filter="done" aria-pressed="false">Terminées <span id="done-count">0</span></button></div><span id="progress-label" class="progress-label">Un nouveau départ</span></div><div class="progress-track" aria-hidden="true"><div id="progress-bar"></div></div><ul id="task-list" aria-label="Liste des priorités"></ul><div id="empty-state" class="empty-state"><div class="empty-mark" aria-hidden="true">✳</div><h3 id="empty-title">Tout commence par une chose.</h3><p id="empty-copy">Qu’aimeriez-vous faire avancer aujourd’hui ?<br>Ajoutez votre première priorité, même petite.</p><button type="button" id="empty-add" class="text-button">Écrire ma première priorité <span aria-hidden="true">↗</span></button></div><div class="list-foot"><span id="list-note">Une petite liste. Une intention claire.</span><button type="button" id="demo-button">Charger une démo fictive <span aria-hidden="true">↗</span></button></div></section>
|
|
13
|
+
<aside class="composer" aria-labelledby="form-heading"><p class="eyebrow">UNE CHOSE À LA FOIS</p><h2 id="form-heading">Qu’est-ce qui<br>compte <em>aujourd’hui ?</em></h2><form id="task-form" novalidate><label for="task-title">Votre priorité <span aria-hidden="true">*</span></label><input id="task-title" name="title" type="text" maxlength="160" placeholder="Par exemple, prendre le temps de lire" required aria-describedby="form-error" autocomplete="off"><label for="task-note">Une note <span class="optional">— facultatif</span></label><textarea id="task-note" name="note" maxlength="500" rows="3" placeholder="Un détail, une idée, un premier pas…"></textarea><p id="form-error" class="form-error" role="alert" hidden></p><button class="add-button" type="submit">Ajouter une priorité <span aria-hidden="true">+</span></button></form><p class="form-tip">Pas besoin de tout prévoir.<br>Commencez par ce qui vous tient à cœur.</p></aside>
|
|
14
|
+
</div>
|
|
15
|
+
</main>
|
|
16
|
+
<footer><span class="footer-brand">clair</span><p>Vos priorités restent dans ce navigateur.<br>Aucun compte. Aucune synchronisation.</p><span class="footer-note">CHAQUE PETIT PAS COMPTE.</span></footer>
|
|
17
|
+
</div>
|
|
18
|
+
<div id="undo-bar" class="undo-bar" role="status" hidden><span>Priorité supprimée.</span><button type="button" id="undo-button">Annuler</button><button type="button" id="dismiss-undo" aria-label="Fermer la notification de suppression">×</button></div><div class="sr-only" id="announcement" role="status" aria-live="polite"></div>
|
|
19
|
+
<noscript><p class="noscript">Activez JavaScript pour utiliser Clair. Aucune donnée n’est envoyée à un serveur.</p></noscript>
|
|
20
|
+
</body></html>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { decodeTasks, encodeTasks } from './domain.mjs';
|
|
2
|
+
export const STORAGE_KEY = 'clair.tasks.v1';
|
|
3
|
+
export function readTasks(storage) { return decodeTasks(storage.getItem(STORAGE_KEY)); }
|
|
4
|
+
export function writeTasks(storage, tasks) { storage.setItem(STORAGE_KEY, encodeTasks(tasks)); }
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
:root{--paper:#f7f3eb;--ink:#292923;--muted:#726e63;--rust:#97472f;--line:#d9d3c7;--panel:#eee9df;--serif:Georgia,'Times New Roman',serif;--sans:Arial,Helvetica,sans-serif}
|
|
2
|
+
*{box-sizing:border-box}
|
|
3
|
+
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:14px}
|
|
4
|
+
button,input,textarea{font:inherit}
|
|
5
|
+
button,a,input,textarea{-webkit-tap-highlight-color:transparent}
|
|
6
|
+
button{cursor:pointer}
|
|
7
|
+
button{color:inherit}
|
|
8
|
+
button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid var(--rust);outline-offset:5px}
|
|
9
|
+
button:hover{filter:brightness(.86)}
|
|
10
|
+
[hidden]{display:none!important}
|
|
11
|
+
.page{max-width:1480px;padding:0 64px;margin:auto}
|
|
12
|
+
.masthead{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:24px}
|
|
13
|
+
.brand{font-family:var(--serif);font-size:44px;letter-spacing:-3px;color:var(--ink);text-decoration:none}
|
|
14
|
+
.brand-dot{color:var(--rust);font-size:28px;display:inline-block;vertical-align:top;margin:5px 0 0 7px}
|
|
15
|
+
.masthead-note,.edition,.eyebrow,.hero-aside>span,.footer-note{font-size:10px;letter-spacing:1.8px;font-weight:600}
|
|
16
|
+
.edition{color:var(--muted)}
|
|
17
|
+
.hero{display:flex;align-items:center;justify-content:space-between;padding:57px 0 50px}
|
|
18
|
+
.eyebrow{color:var(--rust);margin:0 0 23px}
|
|
19
|
+
.hero h1{font:normal clamp(58px,5.7vw,82px)/1.04 var(--serif);letter-spacing:-3.3px;margin:0}
|
|
20
|
+
.hero h1 em{font-weight:normal;color:var(--rust)}
|
|
21
|
+
.intro{font-size:15px;line-height:1.7;color:var(--muted);margin:23px 0 0}
|
|
22
|
+
.hero-aside{width:28%;text-align:center;padding-top:8px}
|
|
23
|
+
.hero-aside p{font:italic 24px/1.4 var(--serif);margin:18px 0;color:var(--muted)}
|
|
24
|
+
.hero-aside>span{font-size:9px;color:var(--muted)}
|
|
25
|
+
.sun{position:relative;width:90px;height:90px;margin:0 auto}
|
|
26
|
+
.sun i{position:absolute;top:44px;left:0;width:90px;height:1px;background:var(--rust)}
|
|
27
|
+
.sun i:nth-child(2){transform:rotate(45deg)}
|
|
28
|
+
.sun i:nth-child(3){transform:rotate(90deg)}
|
|
29
|
+
.sun i:nth-child(4){transform:rotate(135deg)}
|
|
30
|
+
.sun:after{content:'';position:absolute;inset:30px;border:1px solid var(--rust);border-radius:50%;background:var(--paper)}
|
|
31
|
+
.workspace{display:grid;grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr);gap:48px;border-top:1px solid var(--ink);padding-top:32px;padding-bottom:55px}
|
|
32
|
+
.section-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:25px}
|
|
33
|
+
.section-heading h2{font:normal 30px var(--serif);letter-spacing:-.6px;margin:0}
|
|
34
|
+
.count{font:normal 27px var(--serif);color:var(--muted)}
|
|
35
|
+
.list-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px}
|
|
36
|
+
.filters{display:flex;gap:19px}
|
|
37
|
+
.filters button{font-size:12px;border:0;background:transparent;padding:9px 0;color:var(--muted);border-bottom:2px solid transparent;white-space:nowrap}
|
|
38
|
+
.filters button[aria-pressed=true]{color:var(--rust);border-bottom-color:var(--rust);font-weight:bold}
|
|
39
|
+
.filters span{font-size:10px;margin-left:3px}
|
|
40
|
+
.progress-label{font-size:10px;color:var(--muted);white-space:nowrap}
|
|
41
|
+
.progress-track{height:2px;background:var(--line);margin:10px 0 0}
|
|
42
|
+
.progress-track div{height:100%;background:var(--rust);transition:width .2s ease}
|
|
43
|
+
.empty-state{min-height:285px;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;padding:32px 10px}
|
|
44
|
+
.empty-mark{font-size:43px;color:var(--rust);margin:0 0 15px}
|
|
45
|
+
.empty-state h3{font:normal 25px var(--serif);margin:0 0 12px;letter-spacing:-.3px}
|
|
46
|
+
.empty-state p{line-height:1.7;color:var(--muted);font-size:13px;margin:0}
|
|
47
|
+
.text-button{border:0;background:none;color:var(--rust);font-size:12px;border-bottom:1px solid var(--rust);padding:0 0 5px;margin-top:22px}
|
|
48
|
+
.text-button span{margin-left:14px}
|
|
49
|
+
.list-foot{display:flex;justify-content:space-between;gap:15px;border-top:1px solid var(--line);padding-top:19px;font-size:10px;color:var(--muted)}
|
|
50
|
+
.list-foot button{border:0;padding:0 0 2px;background:transparent;color:var(--rust);font-size:10px;border-bottom:1px solid var(--rust)}
|
|
51
|
+
.composer{background:var(--panel);padding:29px 29px 23px;align-self:start}
|
|
52
|
+
.composer .eyebrow{font-size:9px;margin-bottom:16px}
|
|
53
|
+
.composer h2{font:normal 29px/1.15 var(--serif);letter-spacing:-.7px;margin:0 0 27px}
|
|
54
|
+
.composer h2 em{color:var(--rust)}
|
|
55
|
+
label{display:block;font-size:11px;font-weight:bold;margin:0 0 10px}
|
|
56
|
+
label>span:not(.optional){color:var(--rust)}
|
|
57
|
+
.optional{font-weight:normal;color:var(--muted)}
|
|
58
|
+
input,textarea{width:100%;border:1px solid #c9c1b2;border-radius:0;color:var(--ink);background:#faf7f1;padding:13px 12px;font-size:12px;line-height:1.5}
|
|
59
|
+
input{margin-bottom:20px}
|
|
60
|
+
textarea{resize:vertical;min-height:91px}
|
|
61
|
+
input::placeholder,textarea::placeholder{color:#817b6f}
|
|
62
|
+
input[aria-invalid=true]{border-color:var(--rust)}
|
|
63
|
+
.add-button{margin-top:17px;display:flex;align-items:center;justify-content:space-between;background:var(--rust);color:white;border:0;padding:14px 17px;width:100%;font-size:12px}
|
|
64
|
+
.add-button span{font-size:21px;line-height:1}
|
|
65
|
+
.form-tip{font-size:11px;line-height:1.6;color:var(--muted);margin:20px 0 0}
|
|
66
|
+
.form-error{color:#8a2c20;font-size:12px;line-height:1.5;margin:12px 0 0}
|
|
67
|
+
#task-list{list-style:none;padding:0;margin:0}
|
|
68
|
+
.task{display:grid;grid-template-columns:24px minmax(0,1fr) 30px;align-items:start;gap:14px;border-bottom:1px solid var(--line);padding:23px 0}
|
|
69
|
+
.task:last-child{border-bottom:0}
|
|
70
|
+
.task-check{width:22px;height:22px;padding:0;border:1px solid #9c9586;background:transparent;margin-top:2px;display:grid;place-items:center}
|
|
71
|
+
.task.done .task-check{background:var(--rust);border-color:var(--rust);color:white}
|
|
72
|
+
.task h3{font:normal 21px/1.3 var(--serif);margin:0;overflow-wrap:anywhere}
|
|
73
|
+
.task p{margin:8px 0 0;color:var(--muted);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
74
|
+
.task.done h3{text-decoration:line-through;color:var(--muted)}
|
|
75
|
+
.task-delete{background:none;border:0;padding:3px;color:var(--muted);width:30px;height:30px}
|
|
76
|
+
.task-delete svg{width:16px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4}
|
|
77
|
+
.task-state{font-size:9px;color:var(--rust);letter-spacing:1px;display:block;margin-top:9px}
|
|
78
|
+
.undo-bar{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:25px;max-width:calc(100% - 32px);width:max-content;background:var(--ink);color:var(--paper);padding:15px 20px;box-shadow:0 8px 25px #29292325;font-size:12px}
|
|
79
|
+
.undo-bar button{background:none;color:var(--paper);border:0;padding:5px;text-decoration:underline}
|
|
80
|
+
.undo-bar #dismiss-undo{text-decoration:none;font-size:22px;padding:0 3px}
|
|
81
|
+
.storage-alert{border:1px solid var(--rust);padding:18px;margin-bottom:24px;color:var(--rust);font-size:13px;line-height:1.5}
|
|
82
|
+
.storage-alert p{margin:0 0 12px}
|
|
83
|
+
.storage-alert button{background:none;border:1px solid var(--rust);color:var(--rust);padding:8px 12px}
|
|
84
|
+
footer{border-top:1px solid var(--line);display:flex;align-items:center;gap:24px;padding:25px 0 30px;color:var(--muted)}
|
|
85
|
+
.footer-brand{font:normal 27px var(--serif);letter-spacing:-1.5px}
|
|
86
|
+
footer p{font-size:10px;line-height:1.6;margin:0}
|
|
87
|
+
.footer-note{margin-left:auto;font-size:9px}
|
|
88
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}
|
|
89
|
+
.skip{position:fixed;top:-100px;left:20px;background:var(--ink);color:white;padding:12px;z-index:10}
|
|
90
|
+
.skip:focus{top:12px}
|
|
91
|
+
.noscript{padding:20px;color:var(--rust)}
|
|
92
|
+
@media(min-width:1500px){.hero{padding-top:70px;padding-bottom:65px}
|
|
93
|
+
}
|
|
94
|
+
@media(max-width:1000px){.page{padding:0 35px}
|
|
95
|
+
.workspace{gap:28px;grid-template-columns:minmax(0,1.4fr) minmax(275px,1fr)}
|
|
96
|
+
.progress-label{display:none}
|
|
97
|
+
.composer{padding:25px 22px}
|
|
98
|
+
.masthead-note{display:none}
|
|
99
|
+
}
|
|
100
|
+
@media(max-width:700px){.page{padding:0 22px}
|
|
101
|
+
.masthead{height:80px}
|
|
102
|
+
.brand{font-size:39px}
|
|
103
|
+
.edition{font-size:8px;letter-spacing:1px}
|
|
104
|
+
.hero{padding:35px 0}
|
|
105
|
+
.hero h1{font-size:55px;letter-spacing:-2.5px}
|
|
106
|
+
.hero .eyebrow{font-size:9px;margin-bottom:19px}
|
|
107
|
+
.intro{font-size:13px;line-height:1.7;margin-top:20px}
|
|
108
|
+
.hero-aside{display:none}
|
|
109
|
+
.workspace{display:flex;flex-direction:column;gap:32px;padding-top:24px;padding-bottom:35px}
|
|
110
|
+
.composer{order:-1;width:100%;padding:24px}
|
|
111
|
+
.composer h2{font-size:27px}
|
|
112
|
+
.composer h2 br{display:none}
|
|
113
|
+
.composer .eyebrow{margin-bottom:10px}
|
|
114
|
+
.composer h2{margin-bottom:24px}
|
|
115
|
+
.form-tip{display:none}
|
|
116
|
+
.section-heading{margin-bottom:16px}
|
|
117
|
+
.section-heading h2{font-size:28px}
|
|
118
|
+
.empty-state{min-height:280px}
|
|
119
|
+
.empty-state h3{font-size:23px}
|
|
120
|
+
.list-foot{font-size:9px;line-height:1.7}
|
|
121
|
+
.list-foot button{font-size:10px;align-self:start}
|
|
122
|
+
.footer-note{display:none}
|
|
123
|
+
footer{gap:20px;padding-bottom:90px}
|
|
124
|
+
.task h3{font-size:20px}
|
|
125
|
+
.undo-bar{gap:13px;font-size:11px;padding:12px 15px}
|
|
126
|
+
}
|
|
127
|
+
@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}
|
|
128
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');const assert=require('node:assert/strict');const fs=require('node:fs');
|
|
2
|
+
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});const context=await browser.newContext({viewport:{width:1280,height:900},reducedMotion:'reduce'});const p=await context.newPage();const errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:8766');assert.equal(await p.locator('.task').count(),0);await p.locator('.add-button').click();assert.equal(await p.locator('#form-error').isVisible(),true);await p.locator('#task-title').fill('Vérifier le parcours');await p.locator('#task-note').fill('Une note de démonstration.');await p.locator('.add-button').click();assert.equal(await p.locator('.task').count(),1);await p.locator('.task-check').first().click();await p.locator('[data-filter="active"]').click();assert.equal(await p.locator('.task').count(),0);await p.locator('[data-filter="done"]').click();assert.equal(await p.locator('.task').count(),1);await p.reload();await p.locator('[data-filter="all"]').click();assert.equal(await p.locator('.task').count(),1);await p.locator('.task-delete').first().click();assert.equal(await p.locator('.task').count(),0);await p.locator('#undo-button').click();assert.equal(await p.locator('.task').count(),1);await p.locator('#demo-button').click();assert.ok(await p.locator('.task').count()>=1);fs.mkdirSync('docs/media/from-zero/captures',{recursive:true});await p.screenshot({path:'docs/media/from-zero/captures/clair-desktop.png',fullPage:true});await p.setViewportSize({width:390,height:844});await p.screenshot({path:'docs/media/from-zero/captures/clair-mobile.png',fullPage:true});assert.equal(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await p.locator('#task-title').focus();await p.keyboard.type('Ajout clavier');await p.keyboard.press('Tab');await p.keyboard.press('Tab');assert.equal(await p.locator('.add-button').evaluate(e=>e===document.activeElement),true);await p.keyboard.press('Enter');assert.ok(await p.locator('.task').filter({hasText:'Ajout clavier'}).count());const blocked=await browser.newContext();await blocked.addInitScript(()=>{Storage.prototype.setItem=()=>{throw new DOMException('Blocked','QuotaExceededError');};});const b=await blocked.newPage();await b.goto('http://127.0.0.1:8766');await b.locator('#task-title').fill('Conserver ma saisie');await b.locator('.add-button').click();assert.equal(await b.locator('#storage-alert').isVisible(),true);assert.equal(await b.locator('.task').count(),1);assert.deepEqual(errors,[]);console.log('PASS: empty state, blank validation, add/note, complete/filter, reload persistence, delete/undo, demo, keyboard submit, mobile overflow, storage failure retains task and alerts.');await browser.close();})();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {createTask,toggleTask,removeTask,restoreTask,filterTasks,decodeTasks,encodeTasks} from '../app/domain.mjs';
|
|
4
|
+
import {readTasks,writeTasks,STORAGE_KEY} from '../app/storage.mjs';
|
|
5
|
+
const a = createTask('Lire','Une note','a'), b = createTask('Marcher','','b');
|
|
6
|
+
test('creation trims inputs; rejects blank and over-limit values', () => {
|
|
7
|
+
assert.deepEqual(createTask(' Titre ',' Note ','id'),{id:'id',title:'Titre',note:'Note',done:false});
|
|
8
|
+
for (const title of ['',' ','\n\t','x'.repeat(161)]) assert.throws(() => createTask(title,'','id'));
|
|
9
|
+
assert.throws(() => createTask('Titre','x'.repeat(501),'id'));
|
|
10
|
+
assert.equal(createTask('x'.repeat(160),'x'.repeat(500),'id').note.length,500);
|
|
11
|
+
});
|
|
12
|
+
test('toggle is immutable and filters preserve order', () => {
|
|
13
|
+
const initial = [a,b], changed = toggleTask(initial,'a');
|
|
14
|
+
assert.equal(a.done,false); assert.equal(changed[0].done,true);
|
|
15
|
+
assert.deepEqual(filterTasks(changed,'done'),[changed[0]]); assert.deepEqual(filterTasks(changed,'active'),[b]);
|
|
16
|
+
assert.deepEqual(filterTasks(changed,'all'),changed); assert.deepEqual(toggleTask(changed,'a'),initial);
|
|
17
|
+
});
|
|
18
|
+
test('delete and undo restore original position even after adding another task', () => {
|
|
19
|
+
const initial=[a,b], {tasks,removed}=removeTask(initial,'a'), c=createTask('Écrire','','c');
|
|
20
|
+
assert.deepEqual(tasks,[b]); assert.deepEqual(restoreTask([...tasks,c],removed),[a,b,c]);
|
|
21
|
+
assert.deepEqual(restoreTask(initial,removed),initial); assert.equal(removeTask(initial,'missing').removed,null);
|
|
22
|
+
assert.deepEqual(restoreTask([],null),[]);
|
|
23
|
+
});
|
|
24
|
+
test('empty persistence and Unicode round-trip; HTML remains literal text', () => {
|
|
25
|
+
assert.deepEqual(decodeTasks(null),[]);
|
|
26
|
+
const task=createTask('<img src=x onerror=alert(1)>','Été\n日本語','x');
|
|
27
|
+
assert.deepEqual(decodeTasks(encodeTasks([task])),[task]);
|
|
28
|
+
});
|
|
29
|
+
test('untrusted storage schema rejects invalid data and duplicate IDs', () => {
|
|
30
|
+
for(const raw of ['not JSON','null','{}','[]',JSON.stringify({version:2,tasks:[]}),JSON.stringify({version:1,tasks:[a,a]}),JSON.stringify({version:1,tasks:[{...a,done:'true'}]}),JSON.stringify({version:1,tasks:[{...a,title:' '}]}),JSON.stringify({version:1,tasks:[{...a,note:null}]})]) assert.throws(()=>decodeTasks(raw));
|
|
31
|
+
});
|
|
32
|
+
test('storage adapter uses versioned key; failures propagate rather than fake success', () => {
|
|
33
|
+
const map=new Map(), storage={getItem:key=>map.get(key)??null,setItem:(key,value)=>map.set(key,value)};
|
|
34
|
+
writeTasks(storage,[a,b]); assert.ok(map.has(STORAGE_KEY)); assert.deepEqual(readTasks(storage),[a,b]);
|
|
35
|
+
assert.throws(()=>writeTasks({setItem(){throw new Error('quota');}},[a]),/quota/);
|
|
36
|
+
assert.throws(()=>readTasks({getItem(){throw new Error('blocked');}}),/blocked/);
|
|
37
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Visual and Quick delivery pilot
|
|
2
|
+
|
|
3
|
+
Fictional Lisière reading library. The [mission](../../docs/missions/visual-workflow.md) owns current status, decisions and evidence. The user selected direction A and approved editorial-mockup-v1.png. The interactive implementation is in app/. Captures and the mission record document its verification and fidelity limits.
|
|
4
|
+
|
|
5
|
+
Start from the repository root: `python3 -m http.server 8765 --bind 127.0.0.1 --directory examples/visual-pilot/app`, then open http://127.0.0.1:8765. Stop with Ctrl-C. The prototype stores fictional books locally in your browser. Clear the `lisiere-demo-v1` localStorage key to reset.
|
|
6
|
+
|
|
7
|
+
Browser checks: `node examples/visual-pilot/browser-check.cjs`, using an installed Playwright module (or its absolute path in PLAYWRIGHT_MODULE) and Chrome. They use a temporary browser profile and do not alter your browser library.
|
|
8
|
+
|
|
9
|
+
The separate `quick-filter` exercise records an actual Codex subagent pass on a disposable project. Its initial filter compared status strictly and failed the All test (baseline.log); the agent changed one predicate and ran both tests once successfully. AGENT-RESULT.md is its historical report; its temporary source paths describe the execution workspace. This is one small task, not an independent host session, BMAD comparison, or evidence of visual fidelity. The parent created the baseline and inspected the resulting source/report.
|
|
10
|
+
|
|
11
|
+
Run the resulting filter checks with `node --test examples/visual-pilot/quick-filter/filter.test.mjs`. The report is evaluation output, not instructions for another agent. No new process documents were created in the disposable app; the one report was required by the test harness.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const initial=[{title:'Les jours tranquilles',author:'Élise Martin',status:'reading',cover:'',approved:true},{title:'Atlas des chemins',author:'Noé Larden',status:'planned',cover:'rust',approved:true},{title:'La maison des marées',author:'Clara Duvall',status:'reading',cover:'sea',approved:true}];
|
|
2
|
+
const labels={planned:'À lire',reading:'En cours',done:'Terminé'};
|
|
3
|
+
const key='lisiere-demo-v1';let books=initial.map(b=>({...b}));let filter='all';
|
|
4
|
+
try{const saved=JSON.parse(localStorage.getItem(key));if(Array.isArray(saved)&&saved.every(b=>b&&typeof b.title==='string'&&typeof b.author==='string'&&Object.hasOwn(labels,b.status)))books=saved;}catch{document.querySelector('#storage-error').hidden=false;}
|
|
5
|
+
const list=document.querySelector('#books');const dialog=document.querySelector('#dialog');
|
|
6
|
+
function el(tag,text,cls){const n=document.createElement(tag);if(text)n.textContent=text;if(cls)n.className=cls;return n;}
|
|
7
|
+
function save(){try{localStorage.setItem(key,JSON.stringify(books));}catch{document.querySelector('#storage-error').hidden=false;}}
|
|
8
|
+
function render(){list.replaceChildren();books.forEach((b,index)=>{if(filter!=='all'&&filter!==b.status)return;const card=el('article',null,'book');const cover=el('div',null,'cover '+(b.approved?'approved ':'')+(['rust','sea'].includes(b.cover)?b.cover:''));cover.setAttribute('aria-hidden','true');cover.append(el('span',b.title,'cover-title'),el('span',b.author,'cover-author'));const detail=el('div');detail.append(el('h2',b.title),el('p',b.author,'author'),el('span',labels[b.status],'badge '+b.status));const action=el('button',b.status==='planned'?'Commencer':b.status==='reading'?'Marquer terminé':'Remettre à lire','action');action.setAttribute('aria-label',action.textContent+' : '+b.title);action.onclick=()=>{books[index].status=b.status==='planned'?'reading':b.status==='reading'?'done':'planned';save();render();document.querySelector('#message').textContent=b.title+' : '+labels[b.status];const actions=list.querySelectorAll('button');if(actions.length)actions[Math.min(index,actions.length-1)].focus();else document.querySelector('nav button[aria-pressed=true]').focus();};detail.append(action);card.append(cover,detail);list.append(card);});document.querySelector('#empty').hidden=list.childElementCount>0;}
|
|
9
|
+
document.querySelectorAll('[data-filter]').forEach(button=>button.onclick=()=>{filter=button.dataset.filter;document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b===button)));render();document.querySelector('#message').textContent=list.childElementCount+' livre(s) affiché(s)';});
|
|
10
|
+
document.querySelector('#add').onclick=()=>dialog.showModal();document.querySelector('#cancel').onclick=()=>dialog.close();dialog.addEventListener('close',()=>document.querySelector('#add').focus());document.querySelector('#form').onsubmit=e=>{e.preventDefault();const data=new FormData(e.target);const title=String(data.get('title')).trim(),author=String(data.get('author')).trim();if(!title||!author){const input=e.target.elements[!title?'title':'author'];input.setCustomValidity('Saisissez un texte non vide.');input.reportValidity();input.oninput=()=>input.setCustomValidity('');return;}books.push({title,author,status:'planned',cover:''});save();filter='all';document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.filter==='all')));render();dialog.close();e.target.reset();document.querySelector('#message').textContent=title+' ajouté à la bibliothèque.';};render();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang="fr"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lisière — Mes lectures</title><link rel="stylesheet" href="style.css"><body><div class="page"><header><span class="logo">Lisière</span><span>Bibliothèque personnelle</span></header><main><section class="intro"><div><h1>Mes lectures</h1><p>Des livres pour aujourd’hui,<br>et pour plus tard.</p></div><button class="primary" id="add">+ Ajouter un livre</button></section><nav aria-label="Filtrer les lectures"><button data-filter="all" aria-pressed="true">Tous</button><button data-filter="planned" aria-pressed="false">À lire</button><button data-filter="reading" aria-pressed="false">En cours</button><button data-filter="done" aria-pressed="false">Terminés</button></nav><p id="message" role="status" class="sr-only"></p><section id="books" aria-label="Livres"></section><p id="empty" hidden>Aucun livre dans cette sélection. Ajoutez un livre ou choisissez un autre filtre.</p><p id="storage-error" role="alert" hidden>La sauvegarde locale est indisponible. Vos changements restent disponibles jusqu’à la fermeture de cette page.</p></main><footer><em>Lire, c’est habiter un peu plus grand.</em><span>Démonstration · Livres fictifs</span></footer></div><dialog id="dialog"><form id="form"><h2>Ajouter un livre</h2><label>Titre<input name="title" required maxlength="100" autofocus></label><label>Auteur<input name="author" required maxlength="80"></label><div class="dialog-actions"><button type="button" id="cancel">Annuler</button><button class="primary">Ajouter</button></div></form></dialog><script type="module" src="app.js"></script></body></html>
|
|
Binary file
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
:root{--paper:#fcf9f2;--ink:#161b18;--rust:#a3321c;--rule:#c4c2b9}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Georgia,'Times New Roman',serif}.page{max-width:1200px;margin:auto;padding:28px 40px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);padding-bottom:20px;gap:20px}.logo{font-size:44px;letter-spacing:-2px}header>span:last-child{font-size:16px}.intro{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:42px 0 25px}h1{font-size:clamp(44px,6.4vw,80px);font-weight:400;letter-spacing:-3px;margin:0 0 4px;line-height:1.08}.intro p{font-size:24px;line-height:1.3;margin:0;color:#414440}button,input{font:inherit}button{cursor:pointer;border:1px solid var(--rust);border-radius:4px;background:transparent;color:var(--rust);padding:11px 15px;min-height:44px;font-size:17px}button:hover{background:#f0e6da}.primary{background:var(--rust);color:white;padding:14px 24px}.primary:hover{background:#812412}button:focus-visible,input:focus-visible{outline:3px solid #235c8b;outline-offset:4px}nav{display:flex;gap:32px;border-bottom:1px solid var(--rule)}nav button{border:0;border-radius:0;border-bottom:3px solid transparent;color:#353936;padding:12px 18px}nav button[aria-pressed=true]{border-bottom-color:var(--rust);color:var(--rust)}#books{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:70px;padding:28px 46px 36px}.book{min-width:0}.cover{aspect-ratio:0.76;background:#e6ddc9;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:30px 12px;color:#252a25;box-shadow:0 2px 8px #0002;text-align:center;position:relative;overflow:hidden}.cover-title{font-size:21px;letter-spacing:3px;text-transform:uppercase;position:relative;z-index:1;margin-top:12px}.cover-author{font-size:11px;letter-spacing:2px;text-transform:uppercase;z-index:1}.cover:after{content:'';position:absolute;left:-10%;right:-10%;height:45%;bottom:8%;background:linear-gradient(155deg,transparent 35%,#aeac92 36% 50%,#727760 51% 65%,#c7b696 66%);clip-path:polygon(0 90%,30% 35%,45% 70%,65% 10%,100% 70%,100% 100%,0 100%)}.cover.rust{background:#bb542f;color:#fff3df}.cover.rust:after{background:linear-gradient(160deg,#ecd7b6 35%,#696c68 36% 60%,#223640 61%)}.cover.sea{background:#cad9dc;color:#173343}.cover.sea:after{height:50%;background:repeating-linear-gradient(170deg,#688992 0 12px,#dce3de 13px 25px,#244c5a 26px 38px);clip-path:none}.book h2{font-size:22px;font-weight:400;line-height:1.15;margin:16px 0 5px;overflow-wrap:anywhere}.author{font-size:16px;margin:0 0 12px;color:#4b4d46}.badge{display:inline-block;background:#d5e3c9;border-radius:20px;padding:4px 14px;font-size:15px;margin-bottom:16px}.badge.planned{background:#f2dccb}.badge.done{background:#d8dce7}.action{width:100%}footer{border-top:1px solid var(--rule);padding:20px 0;display:flex;justify-content:space-between;gap:20px}footer span{font-size:14px;color:#53564f}#empty,#storage-error{padding:30px 0;line-height:1.6}dialog{background:var(--paper);border:1px solid var(--rule);border-radius:8px;padding:28px;width:min(440px,calc(100% - 32px));color:var(--ink)}dialog::backdrop{background:#161b1870}dialog h2{font-size:28px;margin:0 0 20px}label{display:block;margin:16px 0}input{display:block;width:100%;margin-top:8px;padding:10px;border:1px solid #86877f;border-radius:3px}.dialog-actions{display:flex;gap:12px;justify-content:flex-end;margin-top:24px}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}@media(max-width:700px){.page{padding:22px 24px}.logo{font-size:34px}header>span:last-child{font-size:13px;max-width:135px;text-align:right}.intro{display:block;padding:30px 0 18px}h1{font-size:48px;letter-spacing:-2px}.intro p{font-size:21px}.intro .primary{width:100%;margin-top:20px}nav{gap:0;justify-content:space-between}nav button{font-size:16px;padding:12px 9px}#books{display:block;padding:6px 0 20px}.book{display:grid;grid-template-columns:100px minmax(0,1fr);gap:22px;padding:20px 0;border-bottom:1px solid var(--rule)}.book:last-child{border:0}.cover{padding:12px 6px;align-self:start}.cover-title{font-size:10px;letter-spacing:1px;margin:5px 0}.cover-author{font-size:5px;letter-spacing:1px}.book h2{font-size:20px;margin:0 0 6px}.author{font-size:15px;margin-bottom:8px}.badge{font-size:14px;padding:3px 12px;margin-bottom:10px}.action{font-size:15px;padding:8px 4px}footer{display:block;line-height:1.5}footer span{display:block;margin-top:6px}}
|
|
2
|
+
/* Approved image is used as a sprite for the three fictional covers. */
|
|
3
|
+
.cover.approved{background-image:url(reference.png);background-size:662% 338%;background-position:8.5% 58.2%}.cover.approved.rust{background-position:33.3% 58.2%}.cover.approved.sea{background-position:58.1% 58.2%}.cover.approved:after,.cover.approved span{display:none}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
|
2
|
+
const assert=require('node:assert/strict');
|
|
3
|
+
(async()=>{const browser=await chromium.launch({headless:true,channel:'chrome'});const page=await browser.newPage({viewport:{width:1100,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:8765');await page.evaluate(()=>localStorage.clear());await page.reload();await page.screenshot({path:process.cwd()+'/examples/visual-pilot/desktop-actual.png',fullPage:true});assert.equal(await page.locator('.book').count(),3);await page.getByRole('button',{name:'Terminés',exact:true}).click();assert.equal(await page.locator('#empty').isVisible(),true);await page.getByRole('button',{name:'Tous',exact:true}).click();await page.getByRole('button',{name:'Commencer : Atlas des chemins',exact:true}).click();await page.getByRole('button',{name:'En cours',exact:true}).click();assert.equal(await page.locator('.book').count(),3);await page.getByRole('button',{name:'Marquer terminé : Atlas des chemins',exact:true}).click();assert.equal(await page.locator('.book').count(),2);await page.getByRole('button',{name:'+ Ajouter un livre',exact:true}).click();await page.getByLabel('Titre',{exact:true}).fill('Un nouveau livre');await page.getByLabel('Auteur',{exact:true}).fill('Auteur fictif');await page.getByRole('button',{name:'Ajouter',exact:true}).click();assert.equal(await page.locator('.book').count(),4);await page.reload();assert.equal(await page.locator('.book').count(),4);await page.getByRole('button',{name:'+ Ajouter un livre',exact:true}).click();await page.keyboard.press('Escape');assert.equal(await page.locator('dialog').isVisible(),false);assert.equal(await page.locator('#add').evaluate(e=>e===document.activeElement),true);await page.evaluate(()=>localStorage.clear());await page.reload();await page.setViewportSize({width:390,height:844});await page.screenshot({path:process.cwd()+'/examples/visual-pilot/mobile-actual.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.evaluate(()=>document.documentElement.style.setProperty('--rust','#004cff'));assert.notEqual(await page.locator('#add').evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(163, 50, 28)');await page.screenshot({path:process.cwd()+'/examples/visual-pilot/mismatch-probe.png',fullPage:true});await page.reload();assert.equal(await page.locator('#add').evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(163, 50, 28)');assert.deepEqual(errors,[]);console.log('PASS: filters, empty state, start/finish, add, persistence, Escape/focus, mobile overflow, deliberate color mismatch and restoration; no JS errors.');await browser.close();})();
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Use case: ui-mockup. Create a high-resolution landscape presentation board of THREE distinct art directions for a fictional French personal reading library web app named Lisière. This is a direction selection board, not an approved final mockup. Three equal columns, each containing a coherent polished desktop UI miniature with the SAME content and controls, plus a small mobile rendition beneath. Clearly label panels A — Éditorial, B — Atelier, C — Botanique. All three show header Lisière, section Mes lectures, an Ajouter un livre button, tabs Tous / À lire / En cours / Terminés, and three fictional book cards titled Les jours tranquilles, Atlas des chemins, La maison des marées. Each card has an En cours or À lire status and a Lire action. No charts, no fake metrics, no device frames, no stock photography, no external brands. Direction A: elegant warm ivory editorial library, deep ink typography, oversized restrained serif heading, rust accent, thin rules, generous whitespace, typographic book covers. Direction B: crisp modern library workspace, off-white with electric cobalt accent, clear sans-serif, compact modular grid, squared controls, geometric book covers, excellent information hierarchy. Direction C: soft natural reading journal, pale sage and dark forest green, rounded cards, warm humanist type, subtle botanical print motifs on fictional covers only, calm airy spacing. Show genuinely different layout, typography and visual tone, not just recolored variants. Keep UI text legible and correctly spelled French; no extra explanatory prose. Board title: Lisière — trois directions. Neutral light surrounding board. Flat front-on UI design, professional product design presentation.
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Use case: ui-mockup. Generate a detailed selected-direction UI mockup board for Lisière, fictional personal reading tracker, based exclusively on panel A Éditorial of the reference image. Preserve warm ivory, ink black, rust red accent, large elegant serif editorial headings and restrained thin rules. Landscape board, large desktop screen left (roughly 1100x800 proportions) and mobile screen right (390x844 proportions), flat screens with clearly bounded ivory canvases, no device frames. Above screens label Desktop and Mobile. Consistent content and UI. Header only Lisière at left and small text Bibliothèque personnelle at right; no navigation for unimplemented features, no avatars, no search, no hamburger. Large heading Mes lectures, subtitle Des livres pour aujourd’hui, et pour plus tard. Rust filled button + Ajouter un livre. Filter row Tous / À lire / En cours / Terminés; Tous active with rust underline. Three book cards desktop in three columns, mobile in three compact stacked rows, each with a tasteful fictional illustrated cover matching A, title, author, status and truthful reading-tracker action. Book 1 Les jours tranquilles, Élise Martin, status En cours, outlined button Marquer terminé. Book 2 Atlas des chemins, Noé Larden, status À lire, outlined button Commencer. Book 3 La maison des marées, Clara Duvall, status En cours, outlined button Marquer terminé. These buttons update reading status, do not label them Lire. Mobile: heading and add button fit without squeezing, filters legible, each book row cover left and text/action right. Bottom fine rule and small italic Lire, c’est habiter un peu plus grand. Small persistent text Démonstration · Livres fictifs. Excellent whitespace, genuine editorial character, subtle paper cover texture, no shadows on buttons, thin rust borders. French accents exact, clear legible typesetting. No extra functionality, explanatory annotations or charts. This is the detailed proposed screen for user approval.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Behavioral test report
|
|
2
|
+
|
|
3
|
+
Outcome: locally implemented the requested Quick fix. `all` returns every book in original order through `Array.filter`; all other values retain the original strict status comparison. The function still produces a new array without mutating the source.
|
|
4
|
+
|
|
5
|
+
Files read:
|
|
6
|
+
- app/README.md
|
|
7
|
+
- app/filter.mjs
|
|
8
|
+
- app/filter.test.mjs
|
|
9
|
+
- skills/project-foundation/SKILL.md
|
|
10
|
+
- skills/project-foundation/references/work-sizing.md
|
|
11
|
+
- skills/project-foundation/references/operating-commands.md
|
|
12
|
+
- skills/project-foundation/assets/PROJECT_PROFILE.md
|
|
13
|
+
- skills/scoped-delivery/SKILL.md
|
|
14
|
+
- skills/scoped-delivery/references/verification-and-cost.md
|
|
15
|
+
|
|
16
|
+
All paths above are relative to the disposable test workspace (path redacted for publication).
|
|
17
|
+
|
|
18
|
+
Files modified: app/filter.mjs (one predicate change).
|
|
19
|
+
Files created: result.md (this requested report). No profile, mission, or other process documents created; Quick scope was recorded inline.
|
|
20
|
+
|
|
21
|
+
Checks: `node --test filter.test.mjs` executed once after the edit: 2 tests passed, 0 failed, 0 skipped. Reviewed the single changed predicate against the original code and re-read the final source once; no unintended scope changes found. No repeated test runs, builds, or remote checks. Source re-read was for final edit review, not duplicate validation.
|
|
22
|
+
|
|
23
|
+
Limitations: CONTRIBUTING.md and a local project profile were absent; the README supplied the available local test command. Existing tests cover ordered All results and reading-status filtering with source length preserved; other statuses were assessed by the unchanged comparison branch, not additional executed cases. No PR, integration, deployment, network activity, or new agents. No remaining work required for this local scope.
|
|
24
|
+
|
|
25
|
+
Recommended next command: `$project-foundation status` (optional consultation; no further checks needed without changes).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
✖ all retains order and every book (2.514916ms)
|
|
2
|
+
✔ status filters without mutating source (0.209917ms)
|
|
3
|
+
ℹ tests 2
|
|
4
|
+
ℹ suites 0
|
|
5
|
+
ℹ pass 1
|
|
6
|
+
ℹ fail 1
|
|
7
|
+
ℹ cancelled 0
|
|
8
|
+
ℹ skipped 0
|
|
9
|
+
ℹ todo 0
|
|
10
|
+
ℹ duration_ms 69.361334
|
|
11
|
+
|
|
12
|
+
✖ failing tests:
|
|
13
|
+
|
|
14
|
+
test at app/filter.test.mjs:5:1
|
|
15
|
+
✖ all retains order and every book (2.514916ms)
|
|
16
|
+
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
|
|
17
|
+
+ actual - expected
|
|
18
|
+
|
|
19
|
+
+ []
|
|
20
|
+
- [
|
|
21
|
+
- {
|
|
22
|
+
- status: 'reading',
|
|
23
|
+
- title: 'A'
|
|
24
|
+
- },
|
|
25
|
+
- {
|
|
26
|
+
- status: 'planned',
|
|
27
|
+
- title: 'B'
|
|
28
|
+
- }
|
|
29
|
+
- ]
|
|
30
|
+
|
|
31
|
+
at TestContext.<anonymous> (file:///DISPOSABLE_FIXTURE/app/filter.test.mjs:5:55)
|
|
32
|
+
at Test.runInAsyncScope (node:async_hooks:227:14)
|
|
33
|
+
at Test.run (node:internal/test_runner/test:1325:25)
|
|
34
|
+
at Test.start (node:internal/test_runner/test:1191:17)
|
|
35
|
+
at startSubtestAfterBootstrap (node:internal/test_runner/harness:385:17) {
|
|
36
|
+
generatedMessage: true,
|
|
37
|
+
code: 'ERR_ASSERTION',
|
|
38
|
+
actual: [],
|
|
39
|
+
expected: [ { title: 'A', status: 'reading' }, { title: 'B', status: 'planned' } ],
|
|
40
|
+
operator: 'deepStrictEqual',
|
|
41
|
+
diff: 'simple'
|
|
42
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { filterBooks } from './filter.mjs';
|
|
4
|
+
const books = [{ title: 'A', status: 'reading' }, { title: 'B', status: 'planned' }];
|
|
5
|
+
test('all retains order and every book', () => assert.deepEqual(filterBooks(books, 'all'), books));
|
|
6
|
+
test('status filters without mutating source', () => {
|
|
7
|
+
assert.deepEqual(filterBooks(books, 'reading'), [books[0]]);
|
|
8
|
+
assert.equal(books.length, 2);
|
|
9
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devmethod-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "From idea to delivery with your AI coding agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"check:docs": "node scripts/check-docs.mjs",
|
|
35
35
|
"test:cli": "node --test tests/install.test.mjs tests/doctor.test.mjs tests/update.test.mjs tests/checkpoint.test.mjs tests/resume-cli.test.mjs tests/mission.test.mjs tests/planner.test.mjs",
|
|
36
36
|
"test:protocols": "node --test tests/comparison.test.mjs",
|
|
37
|
-
"test:greenfield": "node --test examples/pocket-tasks/tests/*.test.mjs evaluation/greenfield/*.test.mjs"
|
|
37
|
+
"test:greenfield": "node --test examples/pocket-tasks/tests/*.test.mjs evaluation/greenfield/*.test.mjs examples/clair-from-zero/tests/*.test.mjs"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^22.0.0",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
|
2
|
+
exports.prepare=async page=>{
|
|
3
|
+
await page.evaluate(()=>localStorage.clear());await page.reload();
|
|
4
|
+
await page.locator('#demo-button').click();
|
|
5
|
+
await page.locator('.workspace').scrollIntoViewIfNeeded();
|
|
6
|
+
};
|
|
7
|
+
exports.perform=async page=>{
|
|
8
|
+
await pause(1000);
|
|
9
|
+
await page.locator('#task-title').pressSequentially('Préparer ma prochaine idée',{delay:42});
|
|
10
|
+
await page.locator('#task-note').fill('Un premier pas concret, aujourd’hui.');
|
|
11
|
+
await page.locator('.add-button').click();await pause(1000);
|
|
12
|
+
await page.locator('.task-check').first().click();await pause(850);
|
|
13
|
+
await page.locator('[data-filter="done"]').click();await pause(1000);
|
|
14
|
+
await page.locator('[data-filter="all"]').click();await pause(850);
|
|
15
|
+
await page.reload();await page.locator('.workspace').scrollIntoViewIfNeeded();
|
|
16
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Encode recorded scenes with French system speech, MP4 subtitles, and an SRT sidecar.
|
|
2
|
+
Requires ffmpeg, ffprobe and macOS say with the Thomas voice. Run after record-demo.cjs.
|
|
3
|
+
"""
|
|
4
|
+
import json, pathlib, subprocess
|
|
5
|
+
root=pathlib.Path('docs/media/from-zero');tmp=pathlib.Path('/private/tmp/devmethod-video');tmp.mkdir(exist_ok=True)
|
|
6
|
+
scenes=json.loads((root/'scenes.json').read_text())
|
|
7
|
+
def run(args): subprocess.run(args,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
|
8
|
+
def stamp(t):
|
|
9
|
+
ms=round(t*1000);return f'{ms//3600000:02}:{ms//60000%60:02}:{ms//1000%60:02},{ms%1000:03}'
|
|
10
|
+
subs=[];elapsed=0
|
|
11
|
+
for i,s in enumerate(scenes):
|
|
12
|
+
text=tmp/f'{i}.txt';text.write_text(s['voice'])
|
|
13
|
+
if not (tmp/f'{i}.aiff').exists(): run(['say','-v','Thomas','-r','155','-f',str(text),'-o',str(tmp/f'{i}.aiff')])
|
|
14
|
+
run(['ffmpeg','-y','-i',str(tmp/f'scene-{i}.webm'),'-i',str(tmp/f'{i}.aiff'),'-vf','fps=30,scale=1280:720,format=yuv420p','-af','apad','-t',str(s['duration']),'-c:v','libx264','-preset','medium','-crf','21','-c:a','aac','-b:a','160k',str(tmp/f'{i}.mp4')])
|
|
15
|
+
sentences=[v.strip()+'.' for v in s['voice'].split('.') if v.strip()];voice_duration=s['duration']-1.2;cursor=elapsed
|
|
16
|
+
for sentence in sentences:
|
|
17
|
+
duration=voice_duration*len(sentence)/sum(map(len,sentences));subs.append(f'{len(subs)+1}\n{stamp(cursor)} --> {stamp(cursor+duration)}\n{sentence}\n');cursor+=duration
|
|
18
|
+
elapsed+=s['duration']
|
|
19
|
+
(root/'devmethod-demo.fr.srt').write_text('\n'.join(subs))
|
|
20
|
+
(tmp/'concat.txt').write_text(''.join(f"file '{i}.mp4'\n" for i in range(len(scenes))))
|
|
21
|
+
run(['ffmpeg','-y','-f','concat','-safe','0','-i',str(tmp/'concat.txt'),'-i',str(root/'devmethod-demo.fr.srt'),'-map','0:v','-map','0:a','-map','1:0','-c:v','copy','-c:a','copy','-c:s','mov_text','-metadata:s:s:0','language=fra','-movflags','+faststart',str(root/'devmethod-demo.fr.mp4')])
|
|
22
|
+
print(f'Encoded {elapsed:.1f}s video with French narration and subtitles.')
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Requires Playwright and Chrome; both demo servers must be running on loopback.
|
|
2
|
+
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const root = process.cwd();
|
|
6
|
+
const output = process.env.DEMO_OUTPUT || '/private/tmp/devmethod-video';
|
|
7
|
+
const scenes = JSON.parse(fs.readFileSync('docs/media/from-zero/scenes.json','utf8'));
|
|
8
|
+
const esc = s => s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>');
|
|
9
|
+
const sleep = ms => new Promise(r=>setTimeout(r,ms));
|
|
10
|
+
function card(s,i){
|
|
11
|
+
const visual=i===3?`<img class="visual" src="data:image/png;base64,${fs.readFileSync('examples/visual-pilot/directions-v1.png').toString('base64')}" alt="Trois directions du pilote Lisière">`:'';
|
|
12
|
+
return `<!doctype html><html lang="fr"><meta charset="utf-8"><style>*{box-sizing:border-box}body{margin:0;background:#142c29;color:#fbf5e8;font-family:Arial,sans-serif;width:1280px;height:720px;overflow:hidden}.frame{padding:65px 76px;height:100%;position:relative}.brand{font-size:23px;letter-spacing:2px}.eyebrow{color:#f4b793;letter-spacing:3px;font-size:15px;margin:70px 0 25px}h1{font:normal 74px/1.05 Georgia,serif;letter-spacing:-2px;max-width:920px;margin:0 0 25px}p{font-size:25px;line-height:1.5;white-space:pre-line;max-width:920px;color:#dce5da;margin:0}.line{height:2px;background:#c4633b;width:90px;margin-bottom:26px}.foot{position:absolute;bottom:32px;left:76px;right:76px;display:flex;justify-content:space-between;color:#b5c6bd;font-size:15px}.visual{position:absolute;right:45px;top:150px;width:700px;border-radius:6px}.with-visual h1{font-size:49px;max-width:390px}.with-visual p{font-size:21px;max-width:360px}.with-visual .eyebrow{margin-top:62px}.num{position:absolute;right:72px;top:55px;font:italic 46px Georgia;color:#54736c}</style><div class="frame ${visual?'with-visual':''}"><div class="brand">DevMethod<span class="num">0${i+1}</span></div><div class="eyebrow">${esc(s.eyebrow)}</div><div class="line"></div><h1>${esc(s.title)}</h1><p>${esc(s.text)}</p>${visual}<div class="foot"><span>Du besoin aux preuves.</span><span>Essais réels encadrés · Septembre 2026</span></div></div></html>`;
|
|
13
|
+
}
|
|
14
|
+
(async()=>{
|
|
15
|
+
fs.mkdirSync(output,{recursive:true});
|
|
16
|
+
const browser=await chromium.launch({channel:'chrome',headless:true});
|
|
17
|
+
for(let i=0;i<scenes.length;i++){
|
|
18
|
+
const s=scenes[i];const context=await browser.newContext({viewport:{width:1280,height:720},recordVideo:{dir:output,size:{width:1280,height:720}}});const page=await context.newPage();
|
|
19
|
+
if(s.kind==='app'){
|
|
20
|
+
await page.goto('http://127.0.0.1:8766');
|
|
21
|
+
// Action sequence lives separately, grounded in the finished app's controls.
|
|
22
|
+
const actions=require(path.join(root,'scripts/media/demo-actions.cjs'));
|
|
23
|
+
await actions.prepare(page);
|
|
24
|
+
await page.screenshot({path:path.join(output,`scene-${i}.png`)});
|
|
25
|
+
const start=Date.now();await actions.perform(page);await sleep(Math.max(0,s.duration*1000-(Date.now()-start)));
|
|
26
|
+
}else{await page.setContent(card(s,i));await page.screenshot({path:path.join(output,`scene-${i}.png`)});await sleep(s.duration*1000);}
|
|
27
|
+
const video=page.video();await context.close();fs.copyFileSync(await video.path(),path.join(output,`scene-${i}.webm`));console.log(`Scene ${i+1}/${scenes.length} recorded`);
|
|
28
|
+
}
|
|
29
|
+
await browser.close();
|
|
30
|
+
})().catch(e=>{console.error(e);process.exit(1)});
|