diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f9231f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: Prototype CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Check JavaScript syntax + run: | + find . -maxdepth 1 -type f -name '*.js' -print0 | sort -z | while IFS= read -r -d '' file; do + node --check "$file" + done + - name: Run tests + run: node --test tests/*.test.js diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index be1eb25..6a1d143 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,8 +25,6 @@ jobs: uses: actions/checkout@v4 - name: Configure Pages uses: actions/configure-pages@v5 - with: - enablement: true - name: Upload static site uses: actions/upload-pages-artifact@v3 with: diff --git a/README.md b/README.md index 9d80d9f..a1235b8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Практическая программа по Project Management уровня senior+. +> **Статус:** текущая реализация — reference prototype для проверки продукта и модели обучения. Она не считается целевой архитектурой масштабируемой платформы. Актуальная стратегия и источники истины находятся в [`docs/`](docs/README.md). + Это не курс по Scrum, Jira или PMBOK. Программа учит рассматривать проект как систему преобразования неопределенности в ценный результат и управлять семью потоками: 1. ценность; @@ -21,13 +23,81 @@ - сохранение прогресса и заметок в браузере; - итоговый capstone длительностью 2–4 недели. +## Стратегия развития + +Проект развивается по схеме **A → C**: + +1. текущий сайт сохраняется как reference prototype; +2. на нем проверяются учебные механики и curriculum; +3. подтвержденные требования фиксируются как продуктовые и доменные контракты; +4. после validation gate строится отдельная масштабируемая v1; +5. контент и механики мигрируют в v1 вертикально, только после проверки. + +Не следует расширять текущие `app.js` и `course-data.js` как постоянную платформенную архитектуру. + +## Phase 1 — M01 Learning Validation + +Первый вертикальный validation slice проверяет модуль **M01 «Проект как система»** до начала v1 rewrite. + +Маршрут в прототипе: + +`#/validation/m01` + +Последовательность: + +`baseline → 2 урока + Decision Drills → integrative post-case → real-project transfer → reflection` + +Baseline и post-case оценивают пять измерений reasoning: механизм, доказательства, trade-offs, вмешательство и change condition. Положительный индивидуальный learning signal требует одновременно `post ≥ baseline + 3` и улучшения минимум по двум измерениям. Это development signal, а не автоматический `mastered`. + +Экспериментальный UI и данные намеренно изолированы от legacy-монолитов: + +- `learning-domain.js` — чистая scoring/state логика; +- `m01-validation-data.js` — cases/drills/field/reflection content; +- `m01-validation-app.js` — validation route и browser persistence; +- `m01-validation.css` — отдельные стили. + +Операционный протокол реальных learner sessions: [`docs/validation/M01-VALIDATION-PROTOCOL.md`](docs/validation/M01-VALIDATION-PROTOCOL.md). + +## Документация + +Начать с [`docs/README.md`](docs/README.md). + +Ключевые документы: + +- [`docs/product/PRODUCT.md`](docs/product/PRODUCT.md) — продуктовый контракт; +- [`docs/product/LEARNING_MODEL.md`](docs/product/LEARNING_MODEL.md) — модель обучения; +- [`docs/product/CURRICULUM.md`](docs/product/CURRICULUM.md) — competency/curriculum contract; +- [`docs/content/CONTENT_MODEL.md`](docs/content/CONTENT_MODEL.md) — модель контента; +- [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) — текущая и целевая архитектура; +- [`docs/ROADMAP.md`](docs/ROADMAP.md) — этапы и validation gates. + ## Запуск локально Сайт не требует сборки. Откройте `index.html` или запустите любой статический HTTP-сервер из корня репозитория. ## Хранение данных -Прогресс, ответы диагностики и рабочие заметки хранятся только в `localStorage` браузера. Они не отправляются на сервер. +Все данные остаются в browser `localStorage` и не отправляются на сервер. + +- `pm01-state-v1` — legacy progress, diagnostic, notes и criteria основного курса; +- `pm01-validation-m01-v1` — изолированное состояние M01 validation experiment. + +Раздельные ключи нужны, чтобы legacy `app.js` не мог случайно перезаписать экспериментальные ответы своим in-memory state. + +## Проверка + +CI использует Node.js built-in test runner и проверяет: + +- scoring/promotion/learning-state domain contracts; +- структуру M01 validation content; +- script/style integration и базовые accessibility contracts; +- JavaScript syntax для prototype runtime files. + +Локально: + +```bash +node --test tests/*.test.js +``` ## Публикация diff --git a/app.js b/app.js index 3ef5930..208b64e 100644 --- a/app.js +++ b/app.js @@ -6,13 +6,13 @@ module.lessons.map((lesson) => ({ ...lesson, moduleId: module.id, moduleTitle: module.title })) ); const storageKey = "pm01-state-v1"; - const defaultState = { completed: [], notes: {}, criteria: {}, lastLesson: null, diagnostic: {} }; + const defaultState = { completed: [], notes: {}, criteria: {}, lastLesson: null, diagnostic: {}, lab: {} }; let state = loadState(); function loadState() { try { - const stored = JSON.parse(localStorage.getItem(storageKey)); - return { ...defaultState, ...stored }; + const stored = JSON.parse(localStorage.getItem(storageKey)) || {}; + return { ...defaultState, ...stored, lab: stored.lab || {} }; } catch (_) { return { ...defaultState }; } @@ -32,17 +32,27 @@ .replaceAll("'", "'"); } + function isLessonComplete(lesson) { + if (!lesson || !state.completed.includes(lesson.id)) return false; + return lesson.learningLab ? labReady(lesson).ready : true; + } + + function completedCount() { + return allLessons.filter((lesson) => isLessonComplete(lesson)).length; + } + function progress() { - return Math.round((state.completed.length / allLessons.length) * 100); + return allLessons.length ? Math.round((completedCount() / allLessons.length) * 100) : 0; } function renderSidebarProgress() { const target = document.querySelector("#sidebar-progress"); if (!target) return; + const done = completedCount(); target.innerHTML = `
Прогресс${progress()}%
-
${state.completed.length} из ${allLessons.length} уроков
`; +
${done} из ${allLessons.length} уроков
`; } function setActiveNav(route) { @@ -55,6 +65,7 @@ function showToast(message) { const toast = document.querySelector("#toast"); + if (!toast) return; toast.textContent = message; toast.classList.add("show"); clearTimeout(showToast.timer); @@ -62,15 +73,43 @@ } function moduleCompletion(module) { - const done = module.lessons.filter((lesson) => state.completed.includes(lesson.id)).length; - return { done, total: module.lessons.length, percent: Math.round((done / module.lessons.length) * 100) }; + const done = module.lessons.filter((lesson) => isLessonComplete(lesson)).length; + const total = module.lessons.length; + return { done, total, percent: total ? Math.round((done / total) * 100) : 0 }; } function nextLesson() { - if (state.lastLesson && !state.completed.includes(state.lastLesson)) { - return allLessons.find((lesson) => lesson.id === state.lastLesson) || allLessons[0]; - } - return allLessons.find((lesson) => !state.completed.includes(lesson.id)) || allLessons.at(-1); + const lastLesson = state.lastLesson ? allLessons.find((lesson) => lesson.id === state.lastLesson) : null; + if (lastLesson && !isLessonComplete(lastLesson)) return lastLesson; + return allLessons.find((lesson) => !isLessonComplete(lesson)) || allLessons.at(-1); + } + + function moduleTargetLesson(module) { + return module.lessons.find((lesson) => !isLessonComplete(lesson)) || module.lessons.at(-1); + } + + function ensureLabState(id) { + state.lab ||= {}; + state.lab[id] ||= { drillAnswers: {}, workbook: {} }; + state.lab[id].drillAnswers ||= {}; + state.lab[id].workbook ||= {}; + return state.lab[id]; + } + + function labReady(lesson) { + if (!lesson?.learningLab) return { ready: false, answeredDrills: 0, requiredDrills: 0, completedFields: 0, requiredFields: 0 }; + const lessonState = ensureLabState(lesson.id); + const requiredDrills = lesson.learningLab.drills.filter((drill) => drill.required !== false); + const requiredFields = lesson.learningLab.workbookFields.filter((field) => field.required !== false); + const answeredDrills = requiredDrills.filter((drill) => Boolean(lessonState.drillAnswers[drill.id])).length; + const completedFields = requiredFields.filter((field) => String(lessonState.workbook[field.id] || "").trim().length > 0).length; + return { + ready: answeredDrills === requiredDrills.length && completedFields === requiredFields.length, + answeredDrills, + requiredDrills: requiredDrills.length, + completedFields, + requiredFields: requiredFields.length, + }; } function homeView() { @@ -79,40 +118,41 @@ return `
-

Практическая программа · уровень senior+

-

Инженерия
исполнения

-

Научись управлять не задачами и статусами, а системой: потоком ценности, решений, информации, зависимостей и риска.

+

Практика управления проектами · senior+

+

Управляй системой,
а не списком задач

+

Курс учит находить причину проблем проекта, принимать решения и проверять их на реальной работе. Иди по урокам по порядку — практика встроена в каждый шаг.

-
10модулей
-
20полевых уроков
+
10модулей по порядку
+
20уроков с практикой
8рабочих шаблонов
-
2–4недели на capstone
+
1итоговая работа
-

Основная модель

Семь потоков проекта

Любая системная проблема проекта проявляется как задержка, разрыв или искажение одного из этих потоков.

+

Модель курса

Семь потоков проекта

Это семь мест, где чаще всего ломается работа проекта. В каждом модуле ты научишься замечать один из таких разрывов и исправлять его.

${DATA.flows.map((flow, i) => `
0${i + 1}${flow.name}
`).join("")}
-

Следующий шаг · урок ${String(nextIndex).padStart(2, "0")}

+

Твой следующий шаг · урок ${String(nextIndex).padStart(2, "0")}

${next.title}

${next.thesis}

${next.minutes} минут · ${next.moduleTitle} - Открыть урок → + Продолжить →
-

Маршрут

Не линейный курс, а система практики

Вся программа
+

Основной путь

Один маршрут: урок → практика → следующий урок

Все 10 модулей
${DATA.modules.slice(0, 4).map(moduleCard).join("")}
`; @@ -120,40 +160,151 @@ function moduleCard(module, index = DATA.modules.indexOf(module)) { const completion = moduleCompletion(module); + const target = moduleTargetLesson(module); return `
МОДУЛЬ ${String(index + 1).padStart(2, "0")} · ${module.duration}

${module.title}

${module.outcome}

- +
`; } function courseView() { const hours = DATA.modules.reduce((sum, module) => { - const [min, max] = module.duration.match(/\d+/g).map(Number); + const values = module.duration.match(/\d+/g)?.map(Number) || [0, 0]; + const min = values[0] || 0; + const max = values[1] ?? min; return [sum[0] + min, sum[1] + max]; }, [0, 0]); + const currentModuleIndex = DATA.modules.findIndex((module) => moduleCompletion(module).done < module.lessons.length); return `
-

Учебный маршрут

От диспетчера задач
к архитектору системы

Каждый модуль заканчивается артефактом для реального проекта. Теория считается освоенной только после наблюдаемого изменения системы.

-
${hours[0]}–${hours[1]} ч

общая нагрузка

${progress()}%

пройдено

1

итоговое системное вмешательство

+

Основной путь

10 модулей.
Иди по порядку.

Начни с первого незавершённого урока. В M01 решения и рабочая карта проверяются прямо внутри урока. Проверки и диагностика дополняют путь, но не создают второй курс.

+
${hours[0]}–${hours[1]} ч

ориентир по времени

${progress()}%

пройдено

${completedCount()}/${allLessons.length}

уроков завершено

+
Как двигаться:1. Разбери кейс2. Примени технику3. Заполни рабочий инструмент4. Проверь перенос на проект
${DATA.modules.map((module, index) => { const completion = moduleCompletion(module); - return ``; + const target = moduleTargetLesson(module); + const finished = completion.done === completion.total; + const current = !finished && (currentModuleIndex === index || currentModuleIndex === -1); + const status = finished ? "Завершено" : current ? "Сейчас" : "Дальше"; + return ``; }).join("")}
`; } + function renderLabFeedback(drill, answerId) { + const option = drill.options?.find((item) => item.id === answerId); + if (!option) return `
Выбери вариант, чтобы получить разбор.
`; + const label = Number(option.score) >= 3 ? "Сильный ход" : Number(option.score) >= 2 ? "Неполный диагноз" : "Слабый ход"; + return `
${label}

${escapeHtml(option.feedback)}

`; + } + + function renderLabDrill(drill, lessonState, isCold = false) { + if (!drill) return ""; + const answer = lessonState.drillAnswers[drill.id]; + return `
+

${isCold ? "01 · Сначала реши" : "04 · Exit check"}

+

${escapeHtml(drill.title || "Decision drill")}

+

${escapeHtml(drill.situation || "")}

+
+ ${escapeHtml(drill.prompt || "Что сделаешь?")} +
${(drill.options || []).map((option) => ``).join("")}
+
+ ${renderLabFeedback(drill, answer)} +
`; + } + + function renderLearningLab(lesson, next, done) { + const lab = lesson.learningLab; + const lessonState = ensureLabState(lesson.id); + const readiness = labReady(lesson); + const coldDrill = lab.drills.find((drill) => drill.stage === "cold") || lab.drills[0]; + const laterDrills = lab.drills.filter((drill) => drill !== coldDrill); + return `
+
+

Learning Lab

+

Навык урока

+

${escapeHtml(lab.skill)}

+
+ + ${renderLabDrill(coldDrill, lessonState, true)} + +
+

02 · Сверь мышление

+

${escapeHtml(lab.workedExample.title)}

+
    ${lab.workedExample.steps.map((step) => `
  1. ${escapeHtml(step)}
  2. `).join("")}
+
+ +
+

03 · Техника

+

${escapeHtml(lab.technique.name)}

+

${escapeHtml(lab.technique.purpose)}

+
    ${lab.technique.steps.map((step) => `
  1. ${escapeHtml(step)}
  2. `).join("")}
+
${escapeHtml(lab.technique.model)}
+
+ + ${laterDrills.map((drill) => renderLabDrill(drill, lessonState)).join("")} + +
+

05 · Рабочий инструмент

+

${escapeHtml(lab.workbookTitle)}

+

Заполни поля фактами. Ответы сохраняются в этом браузере автоматически.

+
${lab.workbookFields.map((field) => ``).join("")}
+
+ +
+

06 · Перенос

+

Примени к реальному проекту

+

${escapeHtml(lab.transferPrompt)}

+
+ +
+

${readiness.answeredDrills}/${readiness.requiredDrills} решений · ${readiness.completedFields}/${readiness.requiredFields} полей${readiness.ready ? " · можно завершать урок" : " · заверши обязательные решения и рабочую карту"}

+ + +
+ ${done + ? `${next ? "Продолжить к следующему уроку" : "Вернуться к программе"} →` + : ``} + +
+
+
`; + } + + function renderLegacyPractice(lesson, next, done, checked, ready) { + const required = lesson.criteria.length; + return `
+

Практика

Примени к своему проекту

+
    ${lesson.practice.map((step) => `
  1. ${step}
  2. `).join("")}
+

Проверь результат

+

Отметь пункт только если действительно сделал его. Это не тест — чекбоксы просто помогают понять, готов ли ты идти дальше.

+
${lesson.criteria.map((criterion, criterionIndex) => ``).join("")}
+

${checked.length}/${required} выполнено${ready ? " · можно завершать урок" : " · выполни все пункты, чтобы открыть следующий шаг"}

+ + +
+ ${done + ? `${next ? "Продолжить к следующему уроку" : "Вернуться к программе"} →` + : ``} + +
+
`; + } + function lessonView(id) { const lesson = allLessons.find((item) => item.id === id); if (!lesson) return notFoundView(); state.lastLesson = id; + if (lesson.learningLab) ensureLabState(id); saveState(); const index = allLessons.findIndex((item) => item.id === id); const previous = allLessons[index - 1]; const next = allLessons[index + 1]; const checked = state.criteria[id] || []; - const done = state.completed.includes(id); + const done = isLessonComplete(lesson); + const ready = lesson.learningLab ? labReady(lesson).ready : checked.length >= lesson.criteria.length; return `
@@ -161,31 +312,23 @@

${lesson.moduleTitle} · урок ${String(index + 1).padStart(2, "0")}/${allLessons.length}

${lesson.title}

${lesson.thesis}

-
${lesson.minutes} минутпрактика обязательна
+
${lesson.minutes} минут${done ? "завершён ✓" : lesson.learningLab ? "кейс + техника + инструмент" : "теория + практика"}
-

Смена оптики

${lesson.body.map((paragraph) => `

${paragraph}

`).join("")}
-
${lesson.thesis}
-

Рабочая модель

${lesson.model}
-
-

Полевая работа

Применить на реальном проекте

-
    ${lesson.practice.map((step) => `
  1. ${step}
  2. `).join("")}
-

Доказательства освоения

-
${lesson.criteria.map((criterion, criterionIndex) => ``).join("")}
- - -
- - -
-
+ ${lesson.learningLab ? "" : `

Главная мысль

${lesson.body.map((paragraph) => `

${paragraph}

`).join("")}
`} + ${lesson.learningLab ? renderLearningLab(lesson, next, done) : ` +
${lesson.thesis}
+

Как это работает

${lesson.model}
+ ${renderLegacyPractice(lesson, next, done, checked, ready)}`}
`; @@ -199,8 +342,8 @@ { score: 3, label: "Системно — есть правило, владелец и обратная связь" } ]; return `
-

Baseline · 7 потоков

Диагностика зрелости

-

Оцени не намерения команды, а воспроизводимое поведение системы за последние четыре недели. Результат покажет, с какого модуля начинать.

+

Необязательная самопроверка

Где проект теряет управляемость?

+

Оцени, как проект работал последние четыре недели. Результат покажет слабое место, на которое стоит обратить внимание. Порядок курса при этом не меняется.

${DATA.diagnostics.map((item, index) => `
${index + 1}. ${item.q}
${choices.map((choice) => ``).join("")}
`).join("")}
@@ -213,7 +356,7 @@ if (!target) return; const answers = Object.keys(state.diagnostic); if (answers.length < DATA.diagnostics.length) { - target.innerHTML = `

Результат

${answers.length}/${DATA.diagnostics.length} ответов

Ответь на все вопросы. Оценка сохранится в этом браузере автоматически.

`; + target.innerHTML = `

Результат

${answers.length}/${DATA.diagnostics.length} ответов

Ответь на все вопросы. Ответы сохраняются в этом браузере автоматически.

`; return; } const scores = Object.fromEntries(DATA.flows.map((flow) => [flow.id, []])); @@ -222,52 +365,112 @@ const weakest = [...results].sort((a, b) => a.score - b.score)[0]; const moduleMap = { value: 2, work: 4, information: 8, decisions: 7, dependencies: 3, uncertainty: 6, feedback: 10 }; const recommended = DATA.modules[moduleMap[weakest.id] - 1]; - target.innerHTML = `

Точка старта

${weakest.name}: ${weakest.score}%

Слабейший поток системы. Начни с модуля «${recommended.title}» и повтори диагностику после полевого вмешательства.

${results.map((result) => `
${result.name}${result.score}%
`).join("")}Открыть модуль ${String(moduleMap[weakest.id]).padStart(2, "0")}`; + target.innerHTML = `

Зона внимания

${weakest.name}: ${weakest.score}%

Слабее всего сейчас выглядит поток «${weakest.name}». Продолжай основной путь и обрати особое внимание на модуль «${recommended.title}».

${results.map((result) => `
${result.name}${result.score}%
`).join("")}Вернуться к основному пути`; } function toolkitView() { - return `

Рабочие артефакты

Инструменты,
которые меняют решения

Скачай Markdown-шаблон, заполни фактами реального проекта и принеси на следующую точку принятия решения.

+ return `

Шаблоны для работы

Инструменты,
которые помогают принять решение

Скачай нужный Markdown-шаблон, заполни его фактами проекта и используй в реальной рабочей ситуации.

${DATA.tools.map((tool, index) => `
TOOL ${String(index + 1).padStart(2, "0")}

${tool.name}

${tool.description}

`).join("")}
-

Правило применения

Артефакт существует ради решения

Есть потребитель

До заполнения ясно, кто и какое решение примет с его помощью.

Есть срок жизни

Устаревший документ удаляют или обновляют, а не хранят как декорацию.

Один источник истины

Информация не копируется вручную между несколькими статусами.

Минимум достаточного

Поле остается только если его отсутствие уже приводило к дорогой ошибке.

+

Правило

Шаблон нужен только тогда, когда помогает решить задачу

Понятно, кому нужен

До заполнения ясно, кто и какое решение примет с его помощью.

Понятно, когда устареет

Ненужный документ удаляют или обновляют, а не хранят ради процесса.

Один источник

Не копируй одну и ту же информацию вручную в несколько мест.

Только нужные поля

Оставляй поле, если без него уже возникали ошибки или плохие решения.

`; } function notFoundView() { - return `

404

Такого урока нет

Вернись к программе и выбери следующий шаг.

Открыть программу
`; + return `

404

Такой страницы нет

Вернись к учебному пути и продолжи с текущего шага.

Открыть учебный путь
`; + } + + function updateLessonCompletionGate(id) { + const criteria = document.querySelectorAll("[data-criterion]"); + const checked = document.querySelectorAll("[data-criterion]:checked").length; + const button = document.querySelector("#complete-lesson"); + const status = document.querySelector("#completion-status"); + const ready = checked >= criteria.length; + if (button) button.disabled = !ready; + if (status) { + status.textContent = `${checked}/${criteria.length} выполнено${ready ? " · можно завершать урок" : " · выполни все пункты, чтобы открыть следующий шаг"}`; + status.classList.toggle("ready", ready); + } + state.criteria[id] = [...document.querySelectorAll("[data-criterion]:checked")].map((item) => Number(item.dataset.criterion)); + saveState(); + } + + function updateLabCompletionGate(id) { + const lesson = allLessons.find((item) => item.id === id); + if (!lesson?.learningLab) return; + const readiness = labReady(lesson); + const button = document.querySelector("#complete-lesson"); + const status = document.querySelector("#completion-status"); + if (button) button.disabled = !readiness.ready; + if (status) { + status.textContent = `${readiness.answeredDrills}/${readiness.requiredDrills} решений · ${readiness.completedFields}/${readiness.requiredFields} полей${readiness.ready ? " · можно завершать урок" : " · заверши обязательные решения и рабочую карту"}`; + status.classList.toggle("ready", readiness.ready); + } } function bindViewEvents(route, id) { if (route === "lesson") { + const lesson = allLessons.find((item) => item.id === id); document.querySelectorAll("[data-scroll]").forEach((link) => { link.addEventListener("click", (event) => { event.preventDefault(); document.querySelector(`#${link.dataset.scroll}`)?.scrollIntoView({ behavior: "smooth" }); }); }); + document.querySelectorAll("[data-criterion]").forEach((checkbox) => { - checkbox.addEventListener("change", () => { - state.criteria[id] = [...document.querySelectorAll("[data-criterion]:checked")].map((item) => Number(item.dataset.criterion)); + checkbox.addEventListener("change", () => updateLessonCompletionGate(id)); + }); + + document.querySelectorAll("[data-lab-drill]").forEach((input) => { + input.addEventListener("change", (event) => { + const drillId = event.target.dataset.labDrill; + const lessonState = ensureLabState(id); + lessonState.drillAnswers[drillId] = event.target.value; + const drill = lesson?.learningLab?.drills.find((item) => item.id === drillId); + const feedback = document.querySelector(`[data-lab-feedback="${drillId}"]`); + if (feedback && drill) feedback.outerHTML = renderLabFeedback(drill, event.target.value); saveState(); + updateLabCompletionGate(id); }); }); + + document.querySelectorAll("[data-lab-field]").forEach((field) => { + field.addEventListener("input", (event) => { + const lessonState = ensureLabState(id); + lessonState.workbook[event.target.dataset.labField] = event.target.value; + saveState(); + updateLabCompletionGate(id); + }); + }); + document.querySelector("#save-notes")?.addEventListener("click", () => { - state.notes[id] = document.querySelector("#lesson-notes").value; + state.notes[id] = document.querySelector("#lesson-notes")?.value || ""; saveState(); showToast("Заметки сохранены"); }); - document.querySelector("#complete-lesson")?.addEventListener("click", (event) => { - state.notes[id] = document.querySelector("#lesson-notes").value; - const completed = state.completed.includes(id); - if (!completed && (state.criteria[id] || []).length < document.querySelectorAll("[data-criterion]").length) { - saveState(); - showToast("Сначала отметь все доказательства освоения"); - return; + + document.querySelector("#complete-lesson")?.addEventListener("click", () => { + state.notes[id] = document.querySelector("#lesson-notes")?.value || ""; + if (lesson?.learningLab) { + if (!labReady(lesson).ready) { + updateLabCompletionGate(id); + showToast("Сначала заверши решения и рабочую карту"); + return; + } + } else { + const criteriaCount = document.querySelectorAll("[data-criterion]").length; + if ((state.criteria[id] || []).length < criteriaCount) { + updateLessonCompletionGate(id); + showToast("Сначала выполни все пункты практики"); + return; + } } - state.completed = completed ? state.completed.filter((item) => item !== id) : [...state.completed, id]; + if (!state.completed.includes(id)) state.completed = [...state.completed, id]; saveState(); - event.currentTarget.textContent = completed ? "Отметить урок пройденным" : "Урок пройден ✓"; - event.currentTarget.classList.toggle("done", !completed); - showToast(completed ? "Отметка снята" : "Урок добавлен в прогресс"); + const index = allLessons.findIndex((item) => item.id === id); + const next = allLessons[index + 1]; + showToast("Урок завершён"); + location.hash = next ? `#/lesson/${next.id}` : "#/course"; }); } @@ -300,28 +503,31 @@ const parts = location.hash.replace(/^#\/?/, "").split("/").filter(Boolean); if (!parts.length) return { route: "home" }; if (parts[0] === "lesson") return { route: "lesson", id: parts[1] }; + if (parts[0] === "validation" && parts[1] === "m01" && parts.length === 2) return { route: "validation-m01" }; if (["course", "diagnostic", "toolkit"].includes(parts[0])) return { route: parts[0] }; return { route: "not-found" }; } function render() { const { route, id } = parseRoute(); + if (route === "validation-m01") return; const views = { home: homeView, course: courseView, lesson: () => lessonView(id), diagnostic: diagnosticView, toolkit: toolkitView, "not-found": notFoundView }; document.querySelector("#main").innerHTML = views[route](); setActiveNav(route); renderSidebarProgress(); bindViewEvents(route, id); - document.querySelector("#main").focus({ preventScroll: true }); + document.querySelector("#main")?.focus({ preventScroll: true }); window.scrollTo(0, 0); - document.querySelector("#mobile-nav").classList.remove("open"); - document.querySelector("#menu-button").setAttribute("aria-expanded", "false"); + document.querySelector("#mobile-nav")?.classList.remove("open"); + document.querySelector("#menu-button")?.setAttribute("aria-expanded", "false"); } - document.querySelector("#menu-button").addEventListener("click", (event) => { + document.querySelector("#menu-button")?.addEventListener("click", (event) => { const nav = document.querySelector("#mobile-nav"); - const open = nav.classList.toggle("open"); + const open = nav?.classList.toggle("open") || false; event.currentTarget.setAttribute("aria-expanded", String(open)); }); + window.addEventListener("hashchange", render); render(); -})(); +})(); \ No newline at end of file diff --git a/art-direction.css b/art-direction.css new file mode 100644 index 0000000..ad59db8 --- /dev/null +++ b/art-direction.css @@ -0,0 +1,409 @@ +:root { + --ink: #f4f2eb; + --ink-2: #111111; + --panel: #151515; + --paper: #f3f0e7; + --paper-2: #dedbd2; + --muted: #aaa9a2; + --text-secondary: #c9c8c1; + --text-tertiary: #aaa9a2; + --accent: #dfff3f; + --accent-2: #dfff3f; + --blue: #dfff3f; + --green: #dfff3f; + --red: #ff705c; + --line: rgba(244,242,235,.20); + --line-strong: rgba(244,242,235,.34); + --paper-line: rgba(10,10,10,.22); + --sidebar: 184px; + --radius: 0px; + color: var(--ink); + background: #0a0a0a; + font-family: Arial, Helvetica, sans-serif; +} + +html, body { background: #0a0a0a; color: var(--ink); } +body { overflow-x: hidden; } +a { color: inherit; } + +*:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} + +/* FRAME */ +.sidebar { + width: var(--sidebar); + padding: 26px 20px 22px; + border-right: 1px solid var(--line); + background: #0a0a0a; +} +.brand { align-items: flex-start; gap: 10px; } +.brand-mark { + width: 46px; + height: 46px; + border: 1px solid var(--ink); + border-radius: 0; + background: transparent; + color: var(--ink); + font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.brand strong { color: var(--ink); font-size: 20px; letter-spacing: -.06em; } +.brand small { color: var(--text-tertiary); font: 600 9px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; } +.main-nav { margin-top: 76px; gap: 0; border-top: 1px solid var(--line); } +.main-nav a { + display: grid; + grid-template-columns: 30px 1fr; + gap: 8px; + padding: 14px 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 650; +} +.main-nav a span { color: var(--text-tertiary); font: 600 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.main-nav a:hover, .main-nav a.active { color: var(--ink); } +.main-nav a.active { box-shadow: inset 3px 0 0 var(--accent); padding-left: 10px; } +.main-nav a.active span { color: var(--accent); } +.sidebar-progress { margin-top: auto; padding: 14px 0 0; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; } +.sidebar-progress .label-row { color: var(--text-tertiary); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.progress-track { height: 2px; margin-top: 10px; border-radius: 0; background: #2a2a28; } +.progress-fill { border-radius: 0; background: var(--accent); } +.sidebar-note { margin: 20px 0 0; color: var(--text-tertiary); font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .05em; text-transform: uppercase; } + +main { margin-left: var(--sidebar); background: #0a0a0a; } +.page { max-width: 1460px; padding: 46px 54px 120px; animation: editorial-enter .32s cubic-bezier(.2,.7,.2,1); } +@keyframes editorial-enter { from { opacity: 0; transform: translateY(10px); } } + +/* TYPE */ +.eyebrow { + margin-bottom: 14px; + color: var(--accent); + font: 700 11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .13em; + text-transform: uppercase; +} +h1, h2, h3 { color: var(--ink); font-family: Arial, Helvetica, sans-serif; } +h1 { font-weight: 800; letter-spacing: -.08em; line-height: .88; } +h2 { font-weight: 760; letter-spacing: -.055em; line-height: .98; } +h3 { font-weight: 720; letter-spacing: -.03em; } +.lead { max-width: 780px; color: var(--text-secondary); font-size: clamp(18px, 1.7vw, 23px); line-height: 1.5; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.muted { color: var(--text-tertiary); } +.accent { color: var(--accent); } +.section { margin-top: 108px; } +.section-heading { align-items: end; padding-top: 16px; border-top: 1px solid var(--line); } +.section-heading p { max-width: 520px; color: var(--text-secondary); line-height: 1.55; } + +/* HERO */ +.hero { position: relative; min-height: 72vh; padding: 18px 0 36px; display: flex; flex-direction: column; justify-content: space-between; } +.hero::after { + display: block; + content: "PM / 0.1"; + position: absolute; + top: 18px; + right: 0; + color: #4b4b47; + writing-mode: vertical-rl; + font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .2em; +} +.hero h1 { max-width: 1100px; margin: 0 0 24px; font-size: clamp(68px, 10.6vw, 158px); } +.hero .lead { margin-top: auto; } +.hero-actions { gap: 0; margin-top: 30px; } + +.button { + min-height: 46px; + padding: 0 17px; + border: 1px solid var(--line-strong); + border-radius: 0; + background: transparent; + color: var(--ink); + box-shadow: none; + font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .04em; + text-transform: uppercase; +} +.button:hover { border-color: var(--ink); background: var(--ink); color: #0a0a0a; } +.button.primary { border-color: var(--accent); background: var(--accent); color: #0a0a0a; } +.button.primary:hover { border-color: var(--ink); background: var(--ink); color: #0a0a0a; } +.button.subtle { min-height: 38px; padding: 0 12px; color: var(--ink); } +.button.subtle:hover { border-color: var(--accent); background: transparent; color: var(--accent); } +.button:disabled { opacity: .48; cursor: not-allowed; } + +/* READOUTS */ +.stat-grid { grid-template-columns: repeat(4, 1fr); gap: 0; margin-top: 66px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.stat { min-height: 120px; padding: 18px 18px 16px 0; border-right: 1px solid var(--line); background: transparent; } +.stat + .stat { padding-left: 18px; } +.stat:last-child { border-right: 0; } +.stat strong { color: var(--ink); font-size: clamp(30px, 4vw, 54px); letter-spacing: -.07em; } +.stat span { color: var(--text-tertiary); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.system-map { grid-template-columns: repeat(7, 1fr); gap: 0; border-top: 1px solid var(--line); } +.system-node { min-height: 148px; padding: 14px 12px 16px; border: 0; border-right: 1px solid var(--line); border-radius: 0; background: transparent; box-shadow: none; } +.system-node:last-child { border-right: 0; } +.system-node::before { content: ""; display: block; width: 100%; height: 2px; margin-bottom: 12px; background: var(--node); } +.system-node span { color: var(--text-tertiary); font: 600 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.system-node strong { margin-top: 42px; color: var(--ink); font-size: 13px; line-height: 1.3; } + +/* NEXT ACTION */ +.next-card { grid-template-columns: 1.45fr .55fr; border: 0; border-radius: 0; background: var(--accent); color: #0a0a0a; box-shadow: none; } +.next-card > div { padding: clamp(28px, 5vw, 64px); } +.next-card h2 { color: #0a0a0a; font-size: clamp(42px, 6vw, 82px); } +.next-card p { color: #20201d; } +.next-card .eyebrow { color: #0a0a0a !important; } +.next-card .next-meta { border-left: 1px solid rgba(10,10,10,.35); } +.next-card .button { align-self: stretch; border-color: #0a0a0a; color: #0a0a0a; } +.next-card .button:hover { background: #0a0a0a; color: var(--accent); } + +/* MODULES AS CHAPTERS */ +.module-grid { display: grid; grid-template-columns: 1fr; gap: 0; border-top: 1px solid var(--line); } +.module-card { min-height: 220px; padding: 28px 0 34px 170px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; box-shadow: none; } +.module-card::after { right: auto; left: 0; bottom: 12px; color: #292925; font-size: 118px; font-weight: 850; letter-spacing: -.1em; } +.module-card .module-kicker { color: var(--accent); font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; } +.module-card h3 { max-width: 720px; margin: 18px 0 10px; color: var(--ink); font-size: clamp(34px, 4vw, 60px); } +.module-card p { max-width: 720px; color: var(--text-secondary); font-size: 16px; line-height: 1.55; } +.module-footer { inset: auto 0 26px 170px; } +.module-footer a { color: var(--ink); font: 700 11px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.module-footer a:hover { color: var(--accent); } + +/* COURSE */ +.course-intro { grid-template-columns: minmax(0, 1.45fr) minmax(220px, .55fr); gap: 56px; align-items: end; } +.course-intro h1 { font-size: clamp(62px, 8.6vw, 132px); } +.course-metrics { padding: 0 0 0 18px; border: 0; border-left: 1px solid var(--line); border-radius: 0; background: transparent; box-shadow: none; } +.course-metrics strong { color: var(--accent); font-size: 36px; } +.course-metrics p { color: var(--text-tertiary); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.path-note { display: grid; grid-template-columns: 180px repeat(4, 1fr); gap: 0; margin-top: 56px; padding: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--ink); font-size: 12px; } +.path-note strong, .path-note span { padding: 15px 12px; border-right: 1px solid var(--line); } +.path-note span:last-child { border-right: 0; } +.path-note strong { color: var(--accent); font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.path-note span { color: var(--text-secondary); } +.module-list { gap: 0; margin-top: 34px; border-top: 1px solid var(--line); } +.module-row { min-height: 138px; padding: 20px 0; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; box-shadow: none; grid-template-columns: 86px minmax(240px,.9fr) 1.2fr 130px; } +.module-row.current { box-shadow: inset 4px 0 0 var(--accent); padding-left: 16px; } +.module-row.completed { opacity: .72; } +.module-index { color: var(--text-tertiary); font: 650 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.module-status { color: var(--text-tertiary); font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; } +.module-row.current .module-status { color: var(--accent); } +.module-row h2 { color: var(--ink); font-size: clamp(25px, 2.4vw, 38px); } +.module-row p { color: var(--text-secondary); font-size: 15px; line-height: 1.5; } +.module-row a { color: var(--ink); font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.module-row a:hover { color: var(--accent); } + +/* LESSON */ +.lesson-layout { grid-template-columns: minmax(0, 860px) 220px; gap: 64px; } +.lesson-layout > article { padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; } +.lesson-header { margin-bottom: 54px; padding: 10px 0 38px; border-bottom: 1px solid var(--line); } +.lesson-header h1 { font-size: clamp(56px, 7vw, 104px); } +.lesson-header .meta { color: var(--text-tertiary); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.lesson-block { margin: 60px 0; } +.lesson-block h2 { margin-bottom: 20px; font-size: clamp(32px, 3vw, 48px); } +.lesson-block p, .lesson-block li { color: var(--text-secondary); font-size: 18px; line-height: 1.68; } +.insight { margin: 50px 0; padding: 24px 0 24px 24px; border-left: 3px solid var(--accent); background: transparent; color: var(--ink); font-size: clamp(24px, 2.5vw, 36px); font-weight: 700; line-height: 1.25; } +.model-card { margin: 36px 0; padding: 24px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--accent); font: 13px/1.9 ui-monospace, SFMono-Regular, Menlo, monospace; } +.practice { margin-top: 70px; padding: 34px; border: 0; border-radius: 0; background: var(--paper); color: #111; } +.practice h2, .practice h3 { color: #111; } +.practice .eyebrow { color: #111; } +.practice-help, .field-help { color: #4f4f4a; font-size: 14px; } +.criterion { padding: 13px 0; border: 0; border-bottom: 1px solid rgba(10,10,10,.18); border-radius: 0; background: transparent; } +.criterion input { accent-color: #111; } +.completion-status { color: #4f4f4a; font: 700 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.completion-status.ready { color: #111; } +.notes { border: 1px solid rgba(10,10,10,.28); border-radius: 0; background: #fff; color: #111; font-size: 16px; } +.notes:focus { outline: 2px solid #111; border-color: #111; } +.practice .button { border-color: #111; color: #111; } +.practice .button.primary { border-color: #111; background: #111; color: var(--paper); } +.lesson-aside { top: 28px; } +.lesson-aside .toc { padding: 0; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; } +.toc small { display: block; padding: 13px 0; border-bottom: 1px solid var(--line); color: var(--text-tertiary); font: 700 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; } +.toc a { padding: 11px 0; border-bottom: 1px solid var(--line); color: var(--text-secondary); font-size: 12px; } +.toc a:hover { color: var(--accent); } +.lesson-nav { gap: 0; margin-top: 24px; } + +/* M01 LEARNING LAB */ +.learning-lab { + margin-top: 10px; + border-top: 1px solid var(--line); + color: var(--ink); +} +.learning-lab > section, +.learning-lab > header { + padding: 42px 0; + border-bottom: 1px solid var(--line); +} +.lab-intro h2, +.lab-drill h2, +.lab-worked h2, +.lab-technique h2, +.lab-workbook h2, +.lab-transfer h2 { font-size: clamp(30px, 3.4vw, 52px); } +.lab-intro > p:last-child, +.lab-purpose, +.lab-situation, +.lab-worked li, +.lab-technique li, +.lab-workbook > p, +.lab-transfer p { + color: var(--text-secondary); + font-size: 17px; + line-height: 1.65; +} +.lab-step { + margin-bottom: 14px; + color: var(--accent); + font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .10em; + text-transform: uppercase; +} +.lab-drill fieldset { margin-top: 24px; padding: 0; border: 0; } +.lab-drill legend { margin-bottom: 14px; color: var(--ink); font-size: 18px; font-weight: 700; line-height: 1.45; } +.lab-options { display: grid; border-top: 1px solid var(--line); } +.lab-option { + display: grid; + grid-template-columns: 24px 1fr; + gap: 12px; + align-items: start; + padding: 16px 0; + border-bottom: 1px solid var(--line); + color: var(--text-secondary); + font-size: 16px; + line-height: 1.45; + cursor: pointer; +} +.lab-option:hover { color: var(--ink); } +.lab-option:has(input:checked) { color: var(--ink); box-shadow: inset 3px 0 0 var(--accent); padding-left: 12px; } +.lab-option input { margin-top: 3px; accent-color: var(--accent); } +.lab-feedback { + margin-top: 18px; + padding: 18px 20px; + border-left: 3px solid var(--text-tertiary); + background: #141414; + color: var(--text-secondary); +} +.lab-feedback strong { display: block; margin-bottom: 7px; color: var(--ink); font-size: 15px; } +.lab-feedback p { margin: 0; color: var(--text-secondary); font-size: 16px; line-height: 1.55; } +.lab-feedback.strong { border-left-color: var(--accent); } +.lab-feedback.needs-work { border-left-color: var(--red); } +.lab-worked ol, +.lab-technique ol { padding-left: 22px; } +.lab-worked li, +.lab-technique li { margin: 9px 0; } +.lab-model { + margin-top: 26px; + padding: 20px 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + color: var(--accent); + font: 700 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.lab-workbook { + margin-inline: -28px; + padding: 42px 28px !important; + background: var(--paper); + color: #111; +} +.lab-workbook h2 { color: #111; } +.lab-workbook .lab-step { color: #111; } +.lab-workbook > p { color: #42423e; } +.lab-fields { display: grid; gap: 0; margin-top: 28px; border-top: 1px solid rgba(10,10,10,.22); } +.lab-field { display: grid; gap: 7px; padding: 18px 0; border-bottom: 1px solid rgba(10,10,10,.22); } +.lab-field strong { color: #111; font-size: 16px; } +.lab-field span { color: #4c4c47; font-size: 14px; line-height: 1.45; } +.lab-field textarea { + width: 100%; + min-height: 88px; + margin-top: 5px; + padding: 12px; + border: 1px solid rgba(10,10,10,.30); + border-radius: 0; + background: #fff; + color: #111; + font: 16px/1.5 Arial, Helvetica, sans-serif; + resize: vertical; +} +.lab-field textarea:focus-visible { outline-color: #111; } +.lab-transfer { background: transparent; } +.lab-finish { padding-top: 34px !important; } +.lab-finish .completion-status { color: var(--text-secondary); } +.lab-finish .completion-status.ready { color: var(--accent); } +.lab-finish .field-help { display: block; margin: 5px 0 10px; color: var(--text-tertiary); } +.lab-finish .notes { background: #111; color: var(--ink); border-color: var(--line-strong); } +.lab-finish .notes:focus-visible { outline-color: var(--accent); } + +/* DIAGNOSTIC / TOOLS */ +.question-card, .tool-card, .principle { border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--ink); box-shadow: none; } +.question-card { padding: 22px 0; } +.question-card legend, .tool-card h3, .principle strong { color: var(--ink); } +.option { border: 1px solid var(--line-strong); border-radius: 0; color: var(--text-secondary); background: transparent; } +.option:has(input:checked) { border-color: var(--accent); background: rgba(223,255,63,.08); color: var(--ink); } +.option input { accent-color: var(--accent); } +.diagnostic-result { border: 1px solid var(--line); border-radius: 0; background: #111; color: var(--ink); box-shadow: none; } +.result-empty, .tool-card p, .principle p { color: var(--text-secondary); } +.tool-grid { gap: 0; border-top: 1px solid var(--line); } +.tool-card { min-height: 260px; padding: 26px 22px 26px 0; } + +/* VALIDATION */ +.validation-shell { max-width: 980px; } +.validation-hero { border-bottom-color: var(--line); } +.validation-note { border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 0; background: transparent; color: var(--text-secondary); } +.validation-step { border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--ink); box-shadow: none; padding: 30px 0; } +.validation-step > header p { color: var(--text-secondary); } +.validation-scenario { border-radius: 0; background: #151515; color: var(--text-secondary); } +.validation-option { border-color: var(--line-strong); border-radius: 0; color: var(--text-secondary); background: transparent; } +.validation-option:has(input:checked) { border-color: var(--accent); background: rgba(223,255,63,.08); color: var(--ink); } +.validation-option input { accent-color: var(--accent); } +.validation-feedback { color: var(--text-secondary); } +.validation-reasoning, .validation-evidence textarea { border: 1px solid var(--line-strong); border-radius: 0; background: #111; color: var(--ink); } +.validation-state { border-color: var(--line); border-radius: 0; color: var(--text-secondary); } + +/* MOBILE */ +@media (max-width: 1050px) { + .system-map { grid-template-columns: repeat(4, 1fr); } + .lesson-layout { grid-template-columns: minmax(0, 760px); } + .lesson-aside { position: static; order: -1; } + .lesson-aside .toc { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 900px) { + :root { --sidebar: 0px; } + .sidebar { display: none; } + main { margin-left: 0; } + .mobile-header { display: flex; height: 62px; padding: 0 16px; border-bottom: 1px solid var(--line); background: #0a0a0a; } + .mobile-header .brand-mark { width: 34px; height: 34px; } + .menu-button { border: 1px solid var(--line-strong); border-radius: 0; color: var(--ink); background: transparent; } + .mobile-nav { background: #0a0a0a; border-bottom: 1px solid var(--line); } + .mobile-nav a { border-bottom: 1px solid var(--line); color: var(--ink); } + .page { padding: 34px 18px 80px; } + .hero { min-height: auto; } + .hero h1 { font-size: clamp(58px, 17vw, 92px); } + .stat-grid { grid-template-columns: repeat(2,1fr); } + .stat:nth-child(2) { border-right: 0; } + .stat:nth-child(-n+2) { border-bottom: 1px solid var(--line); } + .system-map { grid-template-columns: repeat(2,1fr); } + .system-node { border-bottom: 1px solid var(--line); } + .next-card { grid-template-columns: 1fr; } + .next-card .next-meta { border-left: 0; border-top: 1px solid rgba(10,10,10,.35); } + .module-card { padding-left: 88px; } + .module-card::after { font-size: 70px; } + .module-footer { left: 88px; } + .course-intro { grid-template-columns: 1fr; } + .path-note { grid-template-columns: 1fr; } + .path-note strong, .path-note span { border-right: 0; border-bottom: 1px solid var(--line); } + .module-row { grid-template-columns: 48px 1fr; gap: 8px 14px; } + .module-row p, .module-row a { grid-column: 2; text-align: left; } + .lesson-layout { grid-template-columns: 1fr; } + .lesson-aside { position: static; } + .learning-lab > section, .learning-lab > header { padding: 34px 0; } + .lab-workbook { margin-inline: -18px; padding: 34px 18px !important; } +} + +@media (max-width: 620px) { + .lesson-header h1 { font-size: clamp(48px, 15vw, 78px); } + .lab-intro > p:last-child, .lab-purpose, .lab-situation, .lab-worked li, .lab-technique li, .lab-workbook > p, .lab-transfer p { font-size: 16px; } +} + +@media (prefers-reduced-motion: reduce) { + .page { animation: none; } + * { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/content-overrides.js b/content-overrides.js new file mode 100644 index 0000000..1e1e87d --- /dev/null +++ b/content-overrides.js @@ -0,0 +1,59 @@ +(function () { + "use strict"; + const DATA = window.PM01; + if (!DATA || !Array.isArray(DATA.modules)) return; + + const module = DATA.modules.find((item) => item.id === "m01"); + if (!module) return; + + module.subtitle = "Смотреть на проект целиком"; + module.outcome = "Разобраться, как устроен твой проект, найти главное слабое место и выбрать одно изменение с понятным эффектом."; + + const first = module.lessons.find((lesson) => lesson.id === "project-system"); + if (first) { + first.title = "Смотри на проект целиком"; + first.thesis = "Проект — это не список задач. Это система, которая должна привести к полезному результату."; + first.body = [ + "Срок, бюджет и список задач важны, но сами по себе не делают проект успешным. Можно всё закончить вовремя и при этом получить результат, который никому не нужен.", + "Поэтому сначала смотри на то, что должно измениться для пользователя или бизнеса. Затем проверь, что мешает команде стабильно прийти к этому результату.", + "Для этого мы используем семь потоков: ценность, работа, информация, решения, зависимости, неопределённость и обратная связь. Если один поток тормозит, проблема часто проявляется совсем в другом месте." + ]; + first.model = "Что есть на входе → как работает команда → что мы узнали → какой результат получили\n\nИщи: где поток тормозит → сколько стоит задержка → что изменить"; + first.practice = [ + "Возьми один текущий или недавний проект.", + "Одним предложением опиши, что должно измениться для пользователя или бизнеса.", + "По каждому из семи потоков запиши один наблюдаемый факт.", + "Выбери один разрыв, который сильнее всего мешает проекту двигаться дальше." + ]; + first.criteria = [ + "Понятно, какой результат должен измениться во внешнем мире", + "Есть по одному факту для всех семи потоков", + "Факты отделены от предположений", + "Выбран один главный разрыв" + ]; + } + + const second = module.lessons.find((lesson) => lesson.id === "system-diagnostic"); + if (second) { + second.title = "Сначала найди причину"; + second.thesis = "Не исправляй первый заметный симптом. Сначала пойми, почему проблема вообще возникла и почему она может повториться."; + second.body = [ + "Если тестировщик нашёл дефект, причина не обязательно в разработчике. Она могла появиться раньше: например, требование было двусмысленным или решение слишком долго ждали от другого человека.", + "Разбирай проблему по цепочке: что мы увидели → как это возникло → какое условие в системе это создало → что нужно изменить.", + "Хорошая причина не звучит как имя человека. Она описывает правило, ограничение, зависимость или способ работы, из-за которого проблема повторится даже с другой командой." + ]; + second.model = "СИМПТОМ\n↓ что мы видим\nМЕХАНИЗМ\n↓ как это возникло\nУСЛОВИЕ СИСТЕМЫ\n↓ почему повторится\nИЗМЕНЕНИЕ\n↓ что делаем иначе"; + second.practice = [ + "Возьми одну повторяющуюся проблему проекта.", + "Опиши минимум три шага от симптома к причине.", + "Найди условие, из-за которого проблема повторится даже с другим человеком.", + "Предложи одно изменение и ранний сигнал, по которому поймёшь, что оно помогает." + ]; + second.criteria = [ + "Причина описывает систему, а не конкретного человека", + "Понятно, как проблема возникает снова", + "Предлагаемое изменение влияет именно на найденную причину", + "Есть ранний сигнал, по которому можно проверить эффект" + ]; + } +})(); diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0749751 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,61 @@ +# PMO01 Documentation Map + +This directory is the source of truth for product, learning, content, architecture, and delivery decisions. + +## Current strategy + +PMO01 follows an **A → C** evolution path: + +1. Treat the current `main` implementation as a **reference prototype**. +2. Validate which learning and product mechanisms are worth keeping. +3. Design a separate scalable v1 architecture. +4. Migrate only validated content, interactions, and data contracts into v1. + +The reference prototype is not the long-term architecture. + +## Documents + +### Product +- `product/PRODUCT.md` — product purpose, users, value proposition, principles, non-goals. +- `product/LEARNING_MODEL.md` — how PMO01 expects learning to happen. +- `product/CURRICULUM.md` — competency map and curriculum contract. +- `product/PRODUCT_REQUIREMENTS.md` — product capabilities and staged requirements. +- `product/METRICS.md` — validation and learning-effectiveness metrics. + +### Content +- `content/CONTENT_MODEL.md` — canonical entities and rules for lessons, drills, cases, assessments, and artifacts. + +### Validation +- `validation/M01-VALIDATION-PROTOCOL.md` — operational protocol for the first end-to-end learner validation cycle. +- `validation/M01-READINESS-AUDIT.md` — curriculum, learning-model, and content-contract audit before real learner sessions. +- `validation/M01-SESSION-RECORD-TEMPLATE.md` — per-participant evidence and facilitator observation template. +- `validation/M01-COHORT-REVIEW-TEMPLATE.md` — cohort synthesis and explicit Phase 1 exit decision record. + +### Architecture +- `architecture/ARCHITECTURE.md` — current-state and target-state architecture. +- `architecture/adr/0001-reference-prototype-to-v1.md` — decision record for the A → C strategy. + +### Delivery +- `ROADMAP.md` — gates from prototype validation to scalable v1. + +### Design specs +- `superpowers/specs/2026-09-03-pmo01-v0-design.md` — historical V0 design. It remains useful context but is not the current architecture source of truth. +- `superpowers/specs/2026-09-06-pmo01-platform-foundation-design.md` — current platform foundation design. + +## Current gate + +The technical/reference-prototype work required to run the first M01 validation cohort is prepared. The project remains in **Phase 1 — Validate the learning model** until real learner evidence is reviewed. + +Do not begin Phase 2 contract freeze solely because the prototype and CI are ready. Phase 2 requires an explicit cohort decision recorded with `validation/M01-COHORT-REVIEW-TEMPLATE.md`. + +## Source-of-truth precedence + +When documents conflict, use this order: + +1. Accepted ADRs. +2. Current platform foundation design. +3. Product and architecture documents listed above. +4. Historical V0 specs. +5. Existing prototype implementation. + +The prototype describes what exists today. It does not override an accepted product or architecture decision for v1. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..6c78040 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,181 @@ +# PMO01 Roadmap + +## Strategy + +PMO01 evolves through validation gates, not feature accumulation. + +```text +Reference prototype +→ learning validation +→ content contract validation +→ v1 architecture decision +→ one-module vertical migration +→ measured parity/improvement +→ broader migration +→ optional platform expansion +``` + +## Phase 0 — Stabilize the reference prototype + +Goal: make the existing product reliable enough to use as a learning experiment without turning it into the permanent architecture. + +Deliverables: + +- current product behavior documented; +- current curriculum treated as candidate content; +- no major new platform subsystems added; +- obvious prototype-breaking defects fixed when they block validation; +- a small set of representative learning paths selected for testing. + +Exit gate: + +- the prototype can support a learner through at least one complete module without critical usability/data-loss issues. + +## Phase 1 — Validate the learning model + +Goal: determine which learning mechanisms actually improve PM reasoning and transfer. + +Deliverables: + +- select one representative module; +- audit lesson outcomes against `CURRICULUM.md`; +- improve at least one decision drill; +- create or validate one integrative case; +- create one real-project field application; +- define a rubric; +- run learner tests using the protocol in `METRICS.md`. + +Exit gate: + +- evidence indicates the module format can reveal and improve reasoning, not merely deliver content; +- major interaction/content failures are known; +- the validated module can be represented by `CONTENT_MODEL.md` without ad hoc exceptions. + +## Phase 2 — Freeze v1 domain contracts + +Goal: define the minimum stable contracts that implementation may depend on. + +Deliverables: + +- final v1 content schema; +- stable content IDs/version rules; +- learning-state transition model; +- assessment/rubric model; +- learner repository interface; +- diagnostic mapping contract; +- migration rules from prototype content/state where relevant. + +Exit gate: + +- contracts can represent the validated module end-to-end; +- unresolved questions are implementation details, not domain ambiguity. + +## Phase 3 — Select v1 technical architecture + +Goal: choose framework and deployment architecture based on validated requirements. + +Deliverables: + +- compare candidate approaches; +- architecture ADR; +- repository/file structure; +- build/test/deploy strategy; +- performance/accessibility constraints; +- plan for local persistence and future remote persistence boundary. + +Likely candidates may include Astro or another TypeScript static-first framework, but no framework is selected by this roadmap. + +Exit gate: + +- selected architecture implements the domain contracts without coupling content to presentation; +- migration cost is understood; +- no deferred subsystem has been smuggled into v1 requirements. + +## Phase 4 — Build one-module v1 vertical slice + +Goal: prove the target architecture with one validated module. + +Deliverables: + +- content validation pipeline; +- content service; +- curriculum navigation; +- learning runtime; +- learner repository implementation; +- decision drill interaction; +- integrative case interaction; +- field application workflow; +- accessibility and core tests; +- deployment. + +Exit gate: + +- the validated module works end-to-end in v1; +- learner experience is at least as good as the prototype; +- content can be edited without touching UI runtime code; +- state transitions and content relationships have automated tests. + +## Phase 5 — Migrate validated PM curriculum + +Goal: move only content that passes curriculum and learning-quality audits. + +Process per module: + +1. audit outcomes and competency mapping; +2. remove duplication; +3. validate drills/cases/application; +4. convert to canonical content model; +5. migrate assets/templates; +6. test content integrity; +7. publish; +8. compare learner behavior/feedback with prototype where possible. + +Exit gate: + +- all production modules satisfy curriculum/content contracts; +- prototype is no longer required for validated PM paths. + +## Phase 6 — Introduce server-backed learner state only if needed + +Possible triggers: + +- cross-device progress is a validated retention need; +- learner work must persist beyond one browser; +- authenticated cohorts/organizations are required; +- analytics require durable learner/event identity; +- paid product requires account entitlement. + +Deliverables require a separate spec/ADR. + +## Phase 7 — Optional intelligence and scale features + +Only after core learning quality and state model are stable, evaluate separately: + +- AI tutor; +- AI-assisted rubric feedback; +- adaptive review; +- personalized sequencing; +- multi-program catalog; +- team/enterprise learning; +- authoring/CMS workflows; +- payments/certificates. + +Each is an independent product decision, not a default consequence of “scaling”. + +## Current gate — complete Phase 1 with real learner evidence + +The platform foundation and the technical M01 validation slice are prepared for review. The remaining Phase 1 gate is empirical, not architectural. + +Before Phase 2 begins: + +1. run at least 5 completed M01 learner sessions using `docs/validation/M01-VALIDATION-PROTOCOL.md`; +2. record each session with `docs/validation/M01-SESSION-RECORD-TEMPLATE.md`; +3. review diagnostic reasoning delta, field transfer, reflection quality, interaction usefulness, reliability/friction, and content-model fit; +4. record one explicit cohort decision with `docs/validation/M01-COHORT-REVIEW-TEMPLATE.md`: + - **Promote to Phase 2**; + - **Revise and retest**; + - **Reject mechanism**. + +`docs/validation/M01-READINESS-AUDIT.md` documents the current curriculum/content-contract fit before learner testing. + +Do not freeze v1 domain contracts, select a framework, or start a v1 migration until the cohort decision is **Promote to Phase 2**. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md new file mode 100644 index 0000000..e94a1af --- /dev/null +++ b/docs/architecture/ARCHITECTURE.md @@ -0,0 +1,259 @@ +# PMO01 Architecture + +## Architecture status + +PMO01 currently has two architectural descriptions: + +1. the working static prototype on `main`; +2. the historical V0 Astro design spec. + +Neither is automatically the target v1 architecture. + +The accepted strategy is **A → C**: + +- preserve the current prototype as a product/learning reference; +- validate useful mechanisms; +- design a scalable v1 architecture from validated requirements; +- migrate selectively. + +## Current reference prototype + +Current implementation characteristics: + +```text +index.html + ↓ +app.js + ├── routing + ├── rendering + ├── learner state + ├── diagnostics + ├── interaction binding + └── toolkit behavior + +course-data.js + ├── flows + ├── modules + ├── lessons + ├── diagnostics + └── tools + +localStorage + └── learner progress / notes / diagnostic answers +``` + +Strengths: + +- extremely low operational complexity; +- deployable as static files; +- useful as a learning-format prototype; +- no backend dependency; +- fast to change during discovery. + +Scaling constraints: + +- content is embedded in JavaScript application data; +- routing, rendering, interaction logic, and learner state are concentrated in one application file; +- binary completion cannot represent stronger learning evidence; +- no explicit content version model; +- diagnostics are coupled directly to prototype data structures; +- adding many programs or authors would increase coupling and review risk; +- server-backed identity, sync, analytics, or AI would require new boundaries rather than incremental additions inside `app.js`. + +## Architectural principles for v1 + +1. **Content is data, not application code.** +2. **Learning domain logic is independent from page rendering.** +3. **Learner state is behind an interface.** Local and remote persistence must be replaceable. +4. **Content IDs and versions are stable.** +5. **Static-first delivery remains preferred while requirements permit it.** +6. **Interactive behavior is introduced only where learning needs it.** +7. **AI is an optional adapter, not the source of curriculum truth.** +8. **Framework choice follows validated product requirements.** Do not select v1 technology solely because the old V0 spec named Astro. + +## Target logical architecture + +```text + ┌─────────────────────┐ + │ Content packages │ + │ programs/modules/...│ + └──────────┬──────────┘ + │ validate/parse + ┌──────────▼──────────┐ + │ Content service │ + │ stable read model │ + └──────────┬──────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ +┌─────────▼────────┐ ┌─────────▼────────┐ ┌────────▼─────────┐ +│ Learning runtime│ │ Curriculum nav │ │ Assessment logic │ +│ state transitions│ │ sequence/context │ │ rubrics/evidence │ +└─────────┬────────┘ └─────────┬────────┘ └────────┬─────────┘ + └────────────────────┼────────────────────┘ + │ + ┌──────────▼──────────┐ + │ Presentation layer │ + │ pages/components/UI │ + └──────────┬──────────┘ + │ + ┌──────────▼──────────┐ + │ Learner repository │ + │ local or remote │ + └─────────────────────┘ +``` + +## Core boundaries + +### 1. Content package + +Owns curriculum definitions and editorial material. + +Does not own: + +- learner progress; +- UI components; +- authentication; +- analytics transport. + +### 2. Content service + +Consumes validated content definitions and exposes a stable application-facing interface. + +Examples of conceptual reads: + +```ts +getProgram(programId) +getModule(moduleId) +getLesson(lessonId) +getNextLearningUnit(currentId) +getAssessment(assessmentId) +``` + +Storage format is hidden behind this boundary. + +### 3. Learning runtime + +Owns state transitions and rules such as: + +- studied; +- applied; +- mastered; +- recent activity; +- evidence references. + +It must not render HTML or know where content files live. + +### 4. Assessment domain + +Owns decision drills, case responses, rubrics, and evidence semantics. + +Automated scoring is optional. The model must allow human/self/AI-assisted evaluation later without redefining content IDs. + +### 5. Learner repository + +Persistence interface for progress, responses, and notes. + +Initial implementations may use browser storage. Future implementations may use a backend. + +The UI must not call `localStorage` directly. + +### 6. Presentation layer + +Owns navigation, reading experience, controls, feedback presentation, and accessibility. + +It consumes domain interfaces and does not define curriculum semantics. + +## Data flow + +### Lesson load + +```text +route +→ content service resolves lesson +→ runtime loads learner state +→ page renders lesson + state +``` + +### Decision drill + +```text +learner selects action +→ assessment domain records response +→ feedback model resolves analysis +→ learner repository persists response +→ UI displays mechanism/trade-offs +``` + +### Field application + +```text +application task +→ learner creates evidence / response +→ repository stores reference or content +→ assessment/runtime updates applied state when criteria are met +``` + +## Error handling principles + +V1 should fail explicitly for content-integrity errors. + +Examples: + +- duplicate IDs; +- missing referenced drill; +- invalid module order; +- unknown competency reference; +- incompatible content version. + +These should fail build/publish validation rather than produce broken learner pages. + +Runtime persistence failures should preserve the learning page and surface a clear state-saving error rather than discard learner input silently. + +## Technology decision + +No final v1 framework is accepted yet. + +The historical Astro recommendation remains a candidate because static-first content and interactive islands fit the product well, but framework selection should be made in a dedicated architecture decision after the validation gate. + +Candidate evaluation criteria: + +- content pipeline quality; +- static generation; +- TypeScript support; +- accessibility ergonomics; +- testability; +- deployment portability; +- incremental interactivity; +- migration cost from the prototype; +- future support for authenticated/server-backed features without corrupting domain boundaries. + +## Migration strategy + +Do not rewrite the entire prototype before validation. + +Migration should happen vertically: + +1. select one validated module; +2. express it in the canonical content model; +3. implement content validation; +4. implement the minimum learning runtime; +5. reproduce its learning experience in v1; +6. compare behavior and usability; +7. only then migrate remaining validated modules. + +The reference prototype remains available for comparison until v1 reaches functional parity for validated learning paths. + +## Testing architecture + +Required test layers for v1: + +- content schema/relationship validation; +- unit tests for learning-state transitions; +- unit tests for diagnostic recommendation rules; +- interaction tests for drills/cases; +- navigation integration tests; +- a small number of end-to-end learner paths; +- accessibility checks for interactive controls. + +Do not build a large E2E suite before domain contracts stabilize. diff --git a/docs/architecture/adr/0001-reference-prototype-to-v1.md b/docs/architecture/adr/0001-reference-prototype-to-v1.md new file mode 100644 index 0000000..c470d51 --- /dev/null +++ b/docs/architecture/adr/0001-reference-prototype-to-v1.md @@ -0,0 +1,101 @@ +# ADR 0001 — Evolve from Reference Prototype to Scalable V1 + +- Status: Accepted +- Date: 2026-09-06 + +## Context + +PMO01 has a working static prototype on `main` and a historical V0 design that proposed Astro, Markdown-first content, and a single FLOW vertical slice. + +The working prototype moved beyond that V0 scope: it contains a broader curriculum, diagnostics, local learner state, notes, and tools. At the same time, its implementation couples content, routing, rendering, learner state, and interactions too tightly for long-term multi-program growth. + +Three options were considered: + +### Option A — Keep evolving the current prototype + +Advantages: + +- low immediate cost; +- preserves working behavior; +- fastest path for experiments. + +Disadvantages: + +- increasing coupling; +- difficult content/version management; +- poor foundation for accounts, richer assessment, multiple curricula, or teams; +- high risk of turning prototype structure into permanent architecture. + +### Option B — Return to the historical V0 Astro design + +Advantages: + +- simpler vertical slice; +- clean static/content-first direction. + +Disadvantages: + +- discards useful work and product discoveries; +- treats an older design document as more authoritative than observed prototype requirements; +- framework choice precedes renewed validation. + +### Option C — Immediately rewrite as a scalable v1 + +Advantages: + +- strongest theoretical separation of concerns; +- removes prototype constraints early. + +Disadvantages: + +- high overengineering risk; +- architecture could encode unvalidated learning interactions; +- migration scope is large before the product has proven what must be retained. + +## Decision + +Use **A → C**. + +1. Preserve the current `main` implementation as a **reference prototype**. +2. Stop treating prototype architecture as the default place for long-term expansion. +3. Validate learning format, curriculum elements, diagnostics, and interactions with real users. +4. Define stable product, learning, content, and data contracts. +5. Design scalable v1 after those contracts are sufficiently validated. +6. Migrate vertically, starting with one validated module. +7. Retire prototype behavior only after v1 reproduces or improves the validated learning experience. + +## Consequences + +### Positive + +- avoids throwing away working product evidence; +- delays irreversible architecture decisions; +- provides a controlled migration path; +- makes content and learning logic first-class domains; +- enables future programs without making them current requirements. + +### Negative + +- two implementations may coexist temporarily; +- some prototype work will not be migrated; +- short-term feature requests may need to be rejected if they deepen prototype coupling; +- migration requires explicit comparison and content audit. + +## Guardrails + +Until a v1 architecture spec is accepted: + +- do not add backend/auth/CMS/AI architecture to the prototype; +- do not expand `course-data.js` as if it were the permanent content system; +- do not turn `app.js` into a generalized platform runtime; +- prototype-only experiments are allowed when cheap and reversible; +- new prototype work should be justified by a validation question. + +## Revisit conditions + +Revisit this ADR only if: + +- the reference prototype proves inadequate even for validation; +- a hard external requirement forces server-backed functionality before the validation gate; +- the content model cannot represent a validated learning module without major special cases; +- evidence shows a different product direction. diff --git a/docs/content/CONTENT_MODEL.md b/docs/content/CONTENT_MODEL.md new file mode 100644 index 0000000..d13019a --- /dev/null +++ b/docs/content/CONTENT_MODEL.md @@ -0,0 +1,188 @@ +# PMO01 Content Model + +## Goal + +Separate learning content from presentation and runtime behavior so that curricula can evolve, be versioned, validated, and reused without editing application code. + +## Canonical entities + +### Program + +A complete curriculum package. + +Required fields: + +```yaml +id: pm-core +version: 1 +slug: project-management +locale: ru +status: draft | validated | published | retired +``` + +### Module + +A coherent competency unit inside a program. + +Required fields: + +```yaml +id: flow-management +program: pm-core +order: 4 +title: Flow management +competencies: + - C4 +estimated_minutes: 240 +``` + +A module also references: + +- lesson IDs; +- integrative case ID; +- field application ID; +- prerequisite competency IDs when necessary. + +### Lesson + +A focused learning unit with one primary outcome. + +Required metadata: + +```yaml +id: queues +module: flow-management +order: 2 +title: Queues +estimated_minutes: 40 +competencies: + - C4 +outcome: "Given delivery evidence, diagnose harmful queue formation and choose a first intervention." +status: draft | validated | published | retired +``` + +Canonical lesson content sections: + +1. problem frame; +2. principle; +3. mental model; +4. mechanism; +5. failure pattern; +6. worked example; +7. decision drill references; +8. application task reference; +9. evidence criteria; +10. reflection prompt. + +The storage format may use Markdown plus structured front matter. UI components must consume parsed content rather than import lesson text from application source files. + +### Decision Drill + +```yaml +id: queues-drill-01 +competencies: + - C4 +scenario: ... +choices: + - id: a + text: ... +analysis: + preferred_choice: b + mechanism: ... + tradeoffs: ... + change_conditions: ... +``` + +A drill may have a preferred action, but feedback must explain context and trade-offs. + +### Integrative Case + +A multi-concept scenario used near the end of a module. + +Required properties: + +- case evidence; +- learner diagnosis prompt; +- intervention decision; +- rationale prompt; +- evidence/change-condition prompt; +- assessment rubric. + +### Field Application + +A task performed on a real or recent project. + +Required properties: + +- context requirement; +- action steps; +- expected artifact; +- evidence criteria; +- reflection prompts; +- privacy guidance when real work data is involved. + +### Artifact Template + +A reusable working document such as an assumption map or dependency map. + +Templates are content assets, not hard-coded download strings in application code. + +### Assessment Rubric + +Rubrics define how reasoning or application is evaluated. + +A rubric must contain observable dimensions rather than generic labels such as "good answer". + +Example dimensions: + +- mechanism identified; +- relevant evidence used; +- trade-offs acknowledged; +- intervention matches diagnosis; +- change condition stated. + +## Learner-state entities + +Content definitions must not contain learner state. + +Learner state belongs to the learning runtime and references immutable content IDs. + +Minimum conceptual state: + +```ts +type LearningState = + | 'unseen' + | 'studied' + | 'applied' + | 'mastered'; +``` + +Runtime records should be keyed by program version + content ID so future content changes do not silently corrupt historical progress. + +## Versioning rules + +1. Editorial correction with unchanged learning outcome may keep the same content version. +2. A changed learning outcome, rubric, or assessment semantics requires a new version. +3. Published IDs are stable and must not be reused for different concepts. +4. Retired content remains resolvable for historical learner records. +5. Migration between program versions must be explicit when accounts/server persistence exist. + +## Validation rules + +Before content is published, automated or editorial validation should verify: + +- unique IDs; +- valid module/lesson references; +- valid competency references; +- deterministic ordering; +- required fields present; +- no broken drill/case/artifact references; +- no published lesson without a measurable outcome. + +## V1 storage recommendation + +Use structured Markdown/YAML or an equivalent repository-native content format first. + +Do not introduce a CMS until authoring friction, multi-author workflows, publishing permissions, or content volume demonstrate the need. + +The application must depend on a content interface, not on the repository storage format directly. This keeps a future CMS migration possible without rewriting learning UI. diff --git a/docs/product/CURRICULUM.md b/docs/product/CURRICULUM.md new file mode 100644 index 0000000..2c0a1e7 --- /dev/null +++ b/docs/product/CURRICULUM.md @@ -0,0 +1,123 @@ +# PMO01 Curriculum Contract + +## Purpose + +The curriculum defines what a strong PM should be able to diagnose and do after completing PMO01. It is independent from the current number of modules or the UI used to deliver them. + +## Organizing model: seven project flows + +PMO01 uses seven flows as the primary diagnostic lens: + +1. **Value** — how work connects to an observable user or business outcome. +2. **Work** — how work moves through the delivery system. +3. **Information** — how quickly relevant facts reach the people who need them. +4. **Decisions** — how decisions are made, owned, and delayed. +5. **Dependencies** — what blocks downstream valuable action. +6. **Uncertainty** — what must be true but has not yet been demonstrated. +7. **Feedback** — how the system detects error and updates behavior. + +These flows are the stable conceptual backbone. Module boundaries may change during content validation. + +## Target competencies + +A learner completing the core PM curriculum should be able to: + +### C1. System diagnosis +- define the project as a system producing an outcome; +- distinguish symptom, mechanism, systemic condition, and intervention; +- identify the flow in which a failure originates rather than only where it appears. + +### C2. Outcome and value reasoning +- distinguish output from outcome; +- express causal assumptions between work and business/user effect; +- identify weak links in an outcome hypothesis. + +### C3. Dependency and criticality reasoning +- model technical, organizational, decision, and external dependencies; +- reason about downstream impact rather than list priority; +- identify constraints and high-leverage nodes. + +### C4. Flow management +- identify queues, work accumulation, batching, handoff loss, and bottlenecks; +- understand why local utilization can reduce system throughput; +- select interventions that improve flow rather than local productivity optics. + +### C5. Uncertainty and risk +- make assumptions explicit; +- prioritize uncertainty by confidence, impact, and reversibility; +- design evidence-producing experiments before irreversible commitments. + +### C6. Decision architecture +- identify decision rights and decision latency; +- design escalation and ownership mechanisms; +- separate reversible from difficult-to-reverse decisions. + +### C7. Information and feedback +- identify missing, delayed, or distorted information; +- design feedback loops with clear consumers and decisions; +- distinguish status reporting from information that changes action. + +### C8. Intervention design +- choose a management intervention based on mechanism rather than symptom; +- define expected effect, risk, early signal, and stop/change condition; +- review intervention results and update the mental model. + +## Curriculum progression + +The canonical progression is: + +```text +Observe the system +→ Model causes and flows +→ Expose uncertainty and constraints +→ Choose an intervention +→ Collect evidence +→ Update the system +``` + +This progression matters more than preserving any historical module numbering. + +## Module contract + +Every production module must define: + +- `id` and title; +- competency targets; +- prerequisite competencies, if truly required; +- 2–5 lessons with distinct learning outcomes; +- at least one decision drill; +- one integrative/boss case; +- one field application artifact or intervention; +- evidence criteria; +- expected learner effort; +- assessment rule for any claimed mastery. + +## Lesson outcome contract + +Each lesson outcome must use observable behavior. Prefer: + +> "Given X project evidence, the learner can diagnose Y and choose Z with an explicit rationale." + +Avoid outcomes such as: + +- understand queues; +- learn dependencies; +- know risk management. + +## Current prototype mapping + +The existing prototype contains 10 modules and 20 lessons organized around the seven flows and adjacent system-management concepts. That content is treated as **candidate curriculum**, not automatically canonical curriculum. + +Before migration to v1, each lesson must be audited against: + +1. a target competency; +2. a unique learning outcome; +3. a valid decision/application activity; +4. evidence criteria; +5. redundancy with neighboring lessons. + +Lessons that fail this audit should be merged, rewritten, or removed rather than migrated unchanged. + +## Future curricula + +The platform architecture should permit other professional curricula, but PMO01 core content remains a separately versioned curriculum package. Future programs must define their own competency maps rather than reuse the seven PM flows by default. diff --git a/docs/product/LEARNING_MODEL.md b/docs/product/LEARNING_MODEL.md new file mode 100644 index 0000000..b756c74 --- /dev/null +++ b/docs/product/LEARNING_MODEL.md @@ -0,0 +1,143 @@ +# PMO01 Learning Model + +## Learning objective + +PMO01 is designed to change how a learner diagnoses and manages projects, not only what they can recall. + +The core instructional loop is: + +```text +Concept + ↓ +Case + ↓ +Decision + ↓ +Feedback + ↓ +Reflection + ↓ +Transfer to a real project + ↓ +Evidence + ↓ +Updated mental model +``` + +## What counts as learning + +A learner has not mastered a concept because they opened a page or selected the expected answer. + +Evidence of learning should progress through four levels: + +1. **Recognize** — identify the concept in a described situation. +2. **Reason** — explain mechanism, consequences, and trade-offs. +3. **Apply** — use the concept in an unfamiliar case. +4. **Transfer** — apply it to a real project and produce evidence from the intervention. + +The platform may track completion before it can reliably track mastery, but it must not label page visits as mastery. + +## Canonical lesson sequence + +A lesson should normally contain: + +1. **Problem frame** — why the concept matters. +2. **Principle** — the central claim. +3. **Mental model** — a reusable representation. +4. **Mechanism** — why the system behaves this way. +5. **Failure pattern** — a plausible but weak management response. +6. **Worked example** — the model applied to a concrete situation. +7. **Decision drill** — a realistic choice with consequences and trade-offs. +8. **Application task** — work on the learner's own project. +9. **Evidence criteria** — observable conditions for claiming the task was completed. +10. **Reflection prompt** — what changed in the learner's model or next decision. + +Not every lesson needs separate UI blocks for all ten stages. The sequence is an instructional contract, not a layout requirement. + +## Decision drills + +Decision drills are not trivia questions. + +A valid drill: + +- describes a realistic project situation; +- provides multiple plausible actions; +- forces prioritization or trade-offs; +- reveals analysis after the learner chooses; +- explains downstream consequences; +- can have a preferred action without pretending all ambiguity disappears. + +A weak drill asks for a definition or rewards superficial recall. + +## Boss cases / integrative cases + +Each module should end with an integrative case that combines multiple concepts from the module. + +A boss case should require the learner to: + +1. diagnose the system; +2. identify missing or misleading evidence; +3. choose an intervention; +4. explain why competing interventions are weaker or premature; +5. state what evidence would cause them to change course. + +## Field practice + +Every module should produce at least one reusable artifact or intervention on a real or recent project. + +Examples: + +- system map; +- assumption map; +- dependency graph; +- decision-latency map; +- experiment design; +- feedback-loop design; +- operating rule; +- intervention review. + +The artifact exists to improve a decision, not to satisfy a template requirement. + +## Feedback model + +Feedback should explain: + +- what mechanism the learner noticed or missed; +- what trade-off their action creates; +- what evidence matters next; +- what alternative action becomes appropriate under different conditions. + +Avoid celebratory correctness UI as the primary signal of learning. + +## Mastery model + +V1 should separate these states: + +- `unseen` — not engaged with; +- `studied` — lesson completed; +- `applied` — application task submitted or explicitly evidenced; +- `mastered` — demonstrated reasoning/application under a defined assessment rule. + +The exact mastery algorithm is intentionally deferred until PMO01 has enough evidence to define it without fake precision. + +## Spacing and retrieval + +For scalable v1, the content model must permit later addition of spaced review and retrieval practice. These are optional future capabilities, not requirements for the reference prototype. + +If added, review should target mental models and decisions, not rote terminology. + +## Personalization + +Personalization should eventually adapt sequence, examples, and review based on demonstrated gaps. It must not replace the curriculum's competency model with opaque AI recommendations. + +The diagnostic can recommend a starting point, but diagnostic scores are hypotheses about learning needs, not proof of competence. + +## Learning-quality gate + +Before a learning interaction is promoted into scalable v1, it should pass three questions: + +1. Does it expose a meaningful reasoning difference between stronger and weaker PM judgment? +2. Does the feedback explain mechanism and trade-offs rather than only the expected answer? +3. Can the learner transfer the idea to a different case or real project? + +If not, the interaction should be revised or removed. diff --git a/docs/product/METRICS.md b/docs/product/METRICS.md new file mode 100644 index 0000000..2271d1b --- /dev/null +++ b/docs/product/METRICS.md @@ -0,0 +1,168 @@ +# PMO01 Metrics and Validation + +## Principle + +PMO01 should optimize for improvement in project-management judgment, not lesson completion alone. + +Metrics are split into: + +1. learning-quality metrics; +2. behavior/engagement metrics; +3. product-quality metrics; +4. migration gates. + +## North-star learning question + +> After using PMO01, can a learner diagnose a project situation and choose a stronger intervention with better reasoning than before? + +No single percentage can prove this. Validation should combine structured assessment and qualitative evidence. + +## Learning-quality metrics + +### L1. Diagnostic reasoning delta + +Measure performance on structurally similar but non-identical cases before and after a module. + +Score dimensions: + +- mechanism identified; +- relevant evidence selected; +- trade-offs acknowledged; +- intervention matches diagnosis; +- change condition stated. + +Use a stable rubric. Do not reuse identical questions for pre/post measurement. + +### L2. Transfer rate + +Definition: + +```text +learners who complete a real-project application with evidence +-------------------------------------------------------------- +learners who study the corresponding module +``` + +This is more important than page completion. + +### L3. Reflection quality + +Sample learner reflections and classify whether they show: + +- changed diagnosis; +- changed planned action; +- new evidence requirement; +- unchanged/restated lesson content only. + +This can be reviewed manually during prototype validation. + +### L4. Delayed retrieval / application + +Where feasible, check whether the learner can apply the concept to a new case after a delay rather than immediately after reading. + +This becomes more important before any mastery label is introduced. + +## Engagement metrics + +These diagnose friction; they are not proof of learning. + +Track when infrastructure exists: + +- module start rate; +- lesson completion rate; +- decision-drill participation; +- field-application start/completion; +- return rate after first session; +- time to first meaningful application; +- abandonment point by learning unit. + +Avoid optimizing for total minutes spent. + +## Product-quality metrics + +### P1. Content integrity + +- zero broken references in published content; +- zero duplicate published IDs; +- 100% of published lessons mapped to measurable outcomes and competencies. + +### P2. Reliability + +For persisted learner work: + +- no silent loss of notes/responses; +- recoverable state after ordinary page reload/navigation; +- explicit error when persistence fails. + +### P3. Accessibility + +Critical learner paths must be keyboard operable and semantically readable. + +### P4. Performance + +Define numerical budgets when the v1 framework/deployment model is selected. Until then, the constraint is static-first rendering with minimal client JavaScript. + +## Prototype validation protocol + +Before broad v1 implementation, validate at least one complete module with real learners. + +Recommended minimum study: + +1. baseline case; +2. module learning experience; +3. boss/integrative case; +4. real-project application; +5. short interview about reasoning and friction; +6. delayed follow-up case when practical. + +The goal is to discover learning and product failure modes, not to establish statistically generalizable effect size at prototype scale. + +## Promotion gate: interaction + +A learning interaction can be promoted into v1 when it demonstrates at least one of: + +- reveals a meaningful reasoning difference; +- changes the learner's diagnosis; +- improves transfer to a real project; +- exposes a misconception worth addressing. + +Interactions that only create engagement or cosmetic progress should not become architectural requirements. + +## Promotion gate: module + +A prototype module is ready for migration when: + +- learning outcomes are explicit; +- every lesson maps to competencies; +- at least one decision drill has useful feedback; +- the integrative case exercises multiple concepts; +- field transfer is possible; +- rubric/evidence criteria are usable; +- major learner friction is known; +- no unresolved content-model special case is required to represent it. + +## V1 success gate + +Do not claim scalable v1 is successful because migration is technically complete. + +V1 must preserve or improve: + +- reading usability; +- reasoning quality; +- transfer completion; +- learner-state reliability; + +while making content maintenance and future program growth materially easier. + +## Metrics anti-patterns + +Do not use these as primary success measures: + +- number of pages; +- number of lessons; +- raw time on site; +- XP earned; +- streak length; +- percentage of content opened. + +They may describe use but not the product's learning promise. diff --git a/docs/product/PRODUCT.md b/docs/product/PRODUCT.md new file mode 100644 index 0000000..6975520 --- /dev/null +++ b/docs/product/PRODUCT.md @@ -0,0 +1,78 @@ +# PMO01 Product Contract + +## Product purpose + +PMO01 is a learning platform for developing senior-level Project Management judgment through diagnosis, decisions, field application, and reflection. + +It is not primarily a reference library, certification-prep course, or tool tutorial. + +## Core promise + +Help a learner move from managing tasks and ceremonies to diagnosing projects as systems and making higher-quality management decisions under uncertainty. + +## Primary learner + +PMO01 initially targets working or recently working project/process/delivery managers who already understand basic PM vocabulary and want to improve decision quality, systems thinking, and practical execution. + +V1 must not require that the learner works in a specific framework such as Scrum, Kanban, SAFe, or PMBOK. + +## Job to be done + +When a project becomes delayed, uncertain, overloaded, politically difficult, or hard to diagnose, the learner should be able to: + +1. identify the system mechanism producing the visible symptom; +2. distinguish output from outcome; +3. surface assumptions and uncertainty; +4. reason about dependencies, queues, constraints, decision latency, and feedback; +5. choose an intervention with explicit trade-offs; +6. collect evidence from the real project; +7. update the diagnosis after observing the result. + +## Product principles + +1. **Decision quality over content consumption.** Reading does not equal mastery. +2. **Real-project transfer over trivia.** Exercises should connect to work the learner actually manages. +3. **Systems thinking over ceremony memorization.** Frameworks are tools, not the organizing model. +4. **Evidence over confidence.** Completion and mastery must be tied to observable evidence where possible. +5. **Reflection over correctness theater.** Plausible trade-offs are more useful than simplistic green/red answers. +6. **Content and learning engine are separate.** The platform must support future curricula without rewriting the application. +7. **Quiet interface.** Editorial readability and reasoning take precedence over decorative gamification. +8. **Progressive architecture.** Do not introduce backend, AI, accounts, or generalized engines before a validated need exists. + +## Product boundaries + +### In scope for the PM curriculum +- systems diagnosis; +- value and outcomes; +- dependencies and flow; +- uncertainty and risk; +- decision architecture; +- information and feedback; +- operating mechanisms; +- interventions and learning loops. + +### Not the primary focus +- Jira usage; +- Scrum role memorization; +- PMBOK terminology drills; +- generic productivity advice; +- motivational content; +- certification exam preparation. + +## Platform ambition + +PMO01 should eventually support multiple professional learning programs while keeping the first PM curriculum coherent and deep. + +Possible future curricula may include Product Management, Team Leadership, Process Management, and AI-enabled management, but none are v1 requirements. + +## Current product state + +The current `main` branch is a reference prototype containing a static browser application, 10 modules / 20 lessons, seven-flow diagnostics, local progress and notes, and a toolkit. + +This prototype is used to validate product and learning assumptions. It is not the target architecture. + +## Definition of product success + +PMO01 succeeds if learners can demonstrate better project diagnosis and intervention reasoning after using the platform, not merely finish lessons. + +The validation model is defined in `METRICS.md`. diff --git a/docs/product/PRODUCT_REQUIREMENTS.md b/docs/product/PRODUCT_REQUIREMENTS.md new file mode 100644 index 0000000..9367d92 --- /dev/null +++ b/docs/product/PRODUCT_REQUIREMENTS.md @@ -0,0 +1,166 @@ +# PMO01 Product Requirements + +## Scope model + +PMO01 evolves through explicit validation gates. Requirements are separated into: + +- **Reference prototype requirements** — what the current implementation is allowed to prove. +- **V1 core requirements** — what the first scalable architecture must support. +- **Deferred capabilities** — features that require evidence before implementation. + +## Reference prototype + +The prototype exists to validate learning format and curriculum assumptions. + +Required behavior: + +- present PM learning content in a readable editorial interface; +- support navigation across the current curriculum; +- store lightweight progress locally; +- store learner notes locally; +- provide a diagnostic starting point; +- expose reusable field-work templates; +- support real-project application tasks; +- remain deployable as a static site. + +The current implementation already demonstrates most of this behavior. + +## V1 core capabilities + +### P1. Structured content ingestion + +The application must load program/module/lesson/case/drill/artifact definitions through a validated content interface. + +Acceptance conditions: + +- UI code contains no lesson body text; +- invalid content references fail validation before publication; +- one content package can be replaced by another without changing core learning UI. + +### P2. Curriculum navigation + +The learner can: + +- browse programs, modules, and lessons; +- continue from recent work; +- understand what a module develops; +- see prerequisite guidance when it materially affects learning. + +### P3. Learning interactions + +The runtime supports at minimum: + +- decision drills; +- integrative cases; +- field application prompts; +- reflection prompts; +- rubric-based assessment data structures. + +Not every assessment must be automatically scored. + +### P4. Progress model + +The product must distinguish engagement from learning evidence. + +Minimum states: + +- unseen; +- studied; +- applied; +- mastered. + +V1 may initially support only part of the transition logic, but the data model must not collapse these states into a single completion boolean. + +### P5. Learner work + +The learner can persist: + +- notes; +- drill/case decisions where useful; +- field application artifacts or structured responses; +- progress state. + +The first scalable release may still store data locally if validation does not yet require accounts. Storage implementation must be isolated behind an interface. + +### P6. Diagnostics + +Diagnostics may recommend where to start or what to revisit. + +Requirements: + +- recommendation logic is inspectable and deterministic unless explicitly redesigned; +- diagnostic result is described as guidance, not proof of mastery; +- diagnostic questions map to competencies or flows. + +### P7. Accessibility and readability + +The learning experience must support: + +- keyboard navigation for interactive controls; +- readable narrow and wide layouts; +- semantic document structure; +- visible focus states; +- sufficient text contrast; +- no essential information conveyed by color alone. + +### P8. Content version awareness + +Learner state must reference stable content IDs and program/content versions so future curriculum changes do not silently rewrite historical meaning. + +### P9. Observability + +Before large-scale growth work, the product must be able to observe the minimum learning funnel defined in `METRICS.md`. + +Instrumentation can be added only when there is a deployment model that can collect data ethically and legally. + +## Deferred capabilities + +Do not implement these without a separate decision/spec: + +- accounts and authentication; +- cloud sync; +- team/organization dashboards; +- payments; +- CMS; +- AI tutor; +- AI assessment; +- social features; +- leaderboards; +- XP/currency systems; +- generalized knowledge graph; +- adaptive sequencing engine; +- native mobile applications; +- certificates; +- enterprise reporting. + +## Quality attributes + +### Maintainability +- content is separated from runtime; +- domain modules have explicit interfaces; +- no single application file owns routing, rendering, learner state, and learning logic at scale. + +### Portability +- core content and learning model should not depend on GitHub Pages; +- hosting can change without rewriting curricula. + +### Testability +- content validation is automated; +- learning-state transitions are unit-testable; +- drill/case behavior is testable independently from full-page rendering; +- critical navigation paths have integration coverage. + +### Performance +- static content should render with minimal client JavaScript; +- interactive code should load only where required where practical; +- performance budgets should be defined when the v1 framework is selected. + +## Release gate for scalable v1 + +Do not start broad feature expansion until: + +1. the prototype learning format has been tested with real learners; +2. at least one module has validated drills, integrative assessment, and field transfer; +3. the canonical content model is stable enough to represent that module without special cases; +4. migration requirements are known; +5. the target architecture has an accepted ADR/spec. diff --git a/docs/superpowers/plans/2026-09-06-m01-learning-validation.md b/docs/superpowers/plans/2026-09-06-m01-learning-validation.md new file mode 100644 index 0000000..2278773 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-m01-learning-validation.md @@ -0,0 +1,164 @@ +# M01 Learning Validation Implementation Plan + +> **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:** Turn M01 `Проект как система` into the first measurable end-to-end learning-validation slice: baseline case → lessons/drills → integrative post-case → field application → reflection/evidence. + +**Architecture:** Keep the existing static prototype and leave legacy `app.js`, `course-data.js`, and `styles.css` unchanged. Add isolated M01 extension files: a pure learning-domain module, structured validation content, a post-router UI extension, and validation-specific CSS. Persist validation work under a separate `pm01-validation-m01-v1` localStorage key so the legacy app cannot erase experiment state when it writes `pm01-state-v1`. + +**Tech Stack:** Static HTML/CSS/JavaScript, browser `localStorage`, Node.js built-in test runner in GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-09-06-pmo01-platform-foundation-design.md` + +## Global Constraints + +- Current application remains a reference prototype, not the target v1 architecture. +- Do not add backend, authentication, CMS, AI tutor/assessment, payments, or cloud sync. +- Do not assign `mastered` automatically from page visits or a single immediate assessment. +- Baseline and post-case must be structurally similar but non-identical. +- Promotion signal: post-case score improves by at least 3 points and at least 2 rubric dimensions improve. +- Learner work must survive ordinary reload/navigation via localStorage. +- New domain logic must be testable outside the DOM. +- M01 validation data must not share a storage key with the legacy in-memory course state. +- Baseline score and option-level explanatory feedback must remain blind until post-case submission so the assessment itself does not become an unplanned teaching intervention. + +--- + +### Task 1: Add CI and failing learning-domain tests + +**Files:** +- Create: `.github/workflows/ci.yml` +- Create: `tests/learning-domain.test.js` + +**Interfaces:** +- Consumes: CommonJS exports from `learning-domain.js`. +- Produces: executable contract for `scoreAssessment`, `promotionDecision`, and `deriveLearningState`. + +- [x] Add GitHub Actions CI running `node --test tests/*.test.js` on pushes and pull requests. +- [x] Add tests for rubric totals, unanswered questions, promotion delta + dimension gate, and non-automatic mastery. +- [x] Verify RED in Actions: tests fail specifically because `learning-domain.js` does not exist. + +### Task 2: Implement the pure learning domain + +**Files:** +- Create: `learning-domain.js` + +**Interfaces:** +- Produces: `scoreAssessment(questions, answers) -> { total, byDimension, max, answered }`. +- Produces: `promotionDecision(baseline, post, options?) -> { promoted, delta, improvedDimensions }`. +- Produces: `deriveLearningState({ studied, fieldApplied, transferEvidence }) -> 'unseen'|'studied'|'applied'|'mastered'`. + +- [x] Implement browser + CommonJS compatible module with no DOM/storage dependencies. +- [x] Validate option scores as integers `0..3`; unanswered questions receive no invented credit. +- [x] Implement default promotion gate: `delta >= 3` and at least two improved dimensions. +- [x] Require explicit transfer evidence for the domain function to return `mastered`; immediate application yields at most `applied`. +- [x] Verify GREEN in Actions. + +### Task 3: Add structured M01 validation content and content tests + +**Files:** +- Create: `m01-validation-data.js` +- Create: `tests/m01-content.test.js` + +**Interfaces:** +- Produces: `window.PM01.m01Validation` with `rubricDimensions`, `baseline`, `decisionDrills`, `postCase`, `fieldApplication`, `reflection`. + +- [x] Add failing VM-based content-contract test before production content. +- [x] Add five stable rubric dimensions: mechanism, evidence, tradeoffs, intervention, changeCondition. +- [x] Add a five-question baseline with inspectable `0..3` options and feedback. +- [x] Add two Decision Drills mapped to `project-system` and `system-diagnostic`. +- [x] Add a non-identical five-question integrative post-case using the same dimensions. +- [x] Add real-project field application requiring project, symptom, mechanism, intervention, signal, evidence, and next decision. +- [x] Add reflection prompts comparing baseline reasoning with later reasoning. +- [x] Verify RED before content and GREEN after content in Actions. + +### Task 4: Integrate the validation route and isolated learner state + +**Files:** +- Modify: `index.html` +- Create: `m01-validation-app.js` +- Create: `tests/static-contract.test.js` +- Create: `tests/m01-app-smoke.test.js` + +**Interfaces:** +- `index.html` loads `course-data.js → m01-validation-data.js → learning-domain.js → app.js → m01-validation-app.js`. +- Validation state persists under `pm01-validation-m01-v1`. +- Existing course state remains under `pm01-state-v1` and is read only to determine whether both M01 lessons are completed. +- Route: `#/validation/m01`. + +- [x] Add failing static-contract tests for script order, route ownership, course CTA, isolated storage, semantic controls, and validation CSS. +- [x] Keep validation persistence separate from legacy storage after identifying stale-state overwrite risk. +- [x] Add staged route: baseline → lesson links/drills → post-case → field application → reflection/result. +- [x] Freeze submitted baseline/post answers and first drill choices at submission. +- [x] Keep baseline score and option-level feedback blind after baseline submission; reveal comparison/results only after post-case submission. +- [x] Show post score, baseline/post comparison, dimension deltas, and promotion signal only after post-case submission. +- [x] Add a VM runtime smoke test for route rendering, post-case gates, course CTA injection, and baseline blinding. +- [x] Verify the baseline-blinding regression test fails before the fix and passes after the fix. +- [x] Use `promotionDecision` for a neutral learning signal, explicitly not mastery. +- [x] Use `deriveLearningState` only up to `applied` in the UI; delayed transfer evidence remains human-review evidence. +- [x] Add M01 validation CTA to the course view without modifying the legacy router implementation. +- [x] Verify RED before UI extension and GREEN after integration. + +### Task 5: Add validation-specific styling and accessibility contracts + +**Files:** +- Create: `m01-validation.css` +- Modify: `tests/static-contract.test.js` + +**Interfaces:** +- Reuses existing typography/button tokens while keeping validation selectors isolated. + +- [x] Add validation step, score, drill, evidence, result, and CTA styles. +- [x] Use native radio/fieldset/legend/textarea/button controls. +- [x] Provide explicit labels for textareas and a polite live region for persistence/validation messages. +- [x] Convey score/state meaning with text rather than color alone. +- [x] Add narrow-screen layout for score/drill grids. +- [x] Verify static contracts pass. + +### Task 6: Document the experiment and prepare review + +**Files:** +- Create: `docs/validation/M01-VALIDATION-PROTOCOL.md` +- Modify: `README.md` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** +- Produces a reproducible learner-session protocol and makes runtime/test boundaries visible to future contributors. + +- [x] Document participant profile, baseline/post sequence, observations, interview prompts, field transfer, delayed follow-up, and anonymized session record. +- [x] Define module-level review across learning signal, transfer, interaction usefulness, friction/reliability, and content-model fit. +- [x] Document the baseline measurement-blinding rule and its rationale. +- [x] Document M01 route, file boundaries, storage keys, and validation protocol in README. +- [x] Add `node --check` for legacy and validation runtime JavaScript to CI. +- [x] Upgrade GitHub Actions runtime dependencies to current major versions used by the workflow. +- [x] Inspect final Actions run after documentation/CI changes and require GREEN. +- [x] Review final diff for scope creep and architecture violations: Phase 1 only; no backend/auth/framework migration; legacy `app.js`, `course-data.js`, and `styles.css` unchanged. +- [x] Update PR #2 from draft after verification; PR #2 is ready-for-review and mergeable. + +## Gate verification record — 2026-09-06 + +### VERIFIED + +- PR #2 head is `050ea6d26197ba68f8e82d8dc34306f4c6678332`; it remains open and is not merged into `main`. +- Stacked documentation head before this reconciliation was `82c4418de634ed2b824de5714bab34dec4e74ae9`. +- GitHub Actions run #49 on that head completed successfully. +- CI executed JavaScript syntax checks and `node --test tests/*.test.js`; all 19 tests passed, 0 failed, 0 skipped. +- Full scope review of PR #2 confirms Phase 1-only changes: validation runtime/data/styles/tests/CI/docs; no backend, auth, CMS, AI subsystem, framework migration, or v1 rewrite. +- Full scope review of the stacked docs layer confirms documentation/readiness-only changes; no runtime/application files changed. + +### UNEXECUTED + +- No merge of PR #2 into `main`. +- No Phase 2 domain-contract freeze. +- No framework selection or v1 migration. +- No real learner cohort execution in this code gate. + +### BLOCKED + +- Phase 2 remains blocked on the empirical learner-evidence gate in Issue #4: at least 5 completed M01 learner sessions and one explicit cohort decision (`Promote`, `Revise and retest`, or `Reject mechanism`). + +### Next step + +1. Require GREEN CI on the new plan-reconciliation HEAD created by this update. +2. If GREEN, treat the technical/scope gate as closed. +3. Execute Issue #4 learner cohort before any Phase 2 work. diff --git a/docs/superpowers/plans/2026-09-07-m01-learning-lab.md b/docs/superpowers/plans/2026-09-07-m01-learning-lab.md new file mode 100644 index 0000000..2086dc3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-m01-learning-lab.md @@ -0,0 +1,100 @@ +# M01 Learning Lab Implementation Plan + +> **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:** Turn M01 into a decision-training lab with immediate feedback, an in-lesson workbook, substantive completion gates, and readable Editorial Instrument styling. + +**Architecture:** Keep `app.js` as the sole owner of normal lesson routes. Add optional `learningLab` metadata to only M01 lessons from a dedicated data file. Extend generic lesson rendering/binding so M01 gets drills/workbook while M02-M10 retain existing behavior. + +**Tech Stack:** Static HTML/CSS/JavaScript, browser localStorage, Node `node:test` CI. + +**Spec:** `docs/superpowers/specs/2026-09-07-m01-learning-lab-design.md` + +## Global Constraints +- Do not change `main`. +- Do not add telemetry. +- Do not add a new learner-facing route or router owner. +- Preserve `pm01-state-v1` backward compatibility. +- M02-M10 completion behavior must remain unchanged. +- Target WCAG AA 4.5:1 for normal learner-facing text. + +--- + +### Task 1: Define lab data contract and load order + +**Files:** +- Create: `m01-learning-lab-data.js` +- Modify: `index.html` +- Test: `tests/static-contract.test.js` + +**Interfaces:** +- Consumes: `window.PM01`, `window.PM01.m01Validation.decisionDrills`. +- Produces: `lesson.learningLab = { technique, workedExample, drills, workbookFields, transferPrompt }` for the two M01 lessons. + +- [ ] Add a failing static-contract test requiring `m01-learning-lab-data.js` after `m01-validation-data.js` and before `app.js`, and requiring both M01 lesson IDs in the lab data file. +- [ ] Run `node --test tests/*.test.js`; verify the new contract fails because the file/load order does not exist. +- [ ] Add `m01-learning-lab-data.js` with one reusable technique/workbook contract per M01 lesson and attach the existing validation decision drill by `lessonId`. +- [ ] Add the script to `index.html` in the required order. +- [ ] Run the full test suite and commit. + +### Task 2: Render decision lab and workbook + +**Files:** +- Modify: `app.js` +- Test: `tests/m01-app-smoke.test.js` + +**Interfaces:** +- Consumes: optional `lesson.learningLab`. +- Produces: DOM classes `learning-lab`, `lab-drill`, `lab-feedback`, `lab-workbook`, `lab-transfer`, plus persisted `state.lab[lessonId]`. + +- [ ] Add a failing VM/static smoke test asserting an M01 lesson includes a decision drill, feedback region, technique, workbook fields, and transfer section while a non-M01 lesson does not include `learning-lab`. +- [ ] Verify RED with `node --test tests/*.test.js`. +- [ ] Extend `defaultState` with `lab: {}` and normalize missing nested lab state when rendering. +- [ ] Render the optional lab sequence before the final completion action: cold/guided decision drill → feedback → worked example → technique → workbook → transfer. +- [ ] Bind drill radio changes to persist the selected option and render option-specific feedback immediately. +- [ ] Bind workbook input/textarea changes to persist field values. +- [ ] Run full tests and commit. + +### Task 3: Gate M01 completion on evidence instead of checkbox self-attestation + +**Files:** +- Modify: `app.js` +- Test: `tests/m01-app-smoke.test.js` + +**Interfaces:** +- Produces: `labReady(lesson, state)` behavior: at least one answered drill and all required workbook fields non-empty. + +- [ ] Add a failing test that M01 completion is disabled until drill + required workbook fields are present, then enabled; verify a representative M02 lesson still uses the existing criteria gate. +- [ ] Verify RED. +- [ ] Add a small readiness helper and M01-specific completion status text. +- [ ] Hide legacy checkbox criteria for lessons with `learningLab`; preserve them for all other lessons. +- [ ] Update completion handler so M01 uses lab readiness and still writes to the existing `completed` array and advances to the next lesson. +- [ ] Run full tests and commit. + +### Task 4: Fix readability without losing art direction + +**Files:** +- Modify: `art-direction.css` +- Test: `tests/static-contract.test.js` + +**Interfaces:** +- Produces: readable essential text tokens and styled lab surfaces/classes. + +- [ ] Add failing CSS contract tests that essential secondary learner text uses a named readable token rather than `#5f5f5a`, `#666660`, or `#777770`, and that lab classes have explicit focus/feedback styles. +- [ ] Verify RED. +- [ ] Introduce `--text-secondary` and `--text-tertiary` values with visibly stronger contrast on `#0a0a0a`. +- [ ] Replace low-contrast essential labels/statuses with the new tokens while leaving decorative numbering subdued. +- [ ] Style `learning-lab`, drill options, feedback, technique, workbook, transfer, and focus states in the Editorial Instrument language. +- [ ] Ensure body/workbook text remains at least 16px where substantive. +- [ ] Run full tests and commit. + +### Task 5: Integration verification and clean Pages preview + +**Files:** +- Production assets only on `gh-pages` after feature CI is GREEN. + +- [ ] Run/confirm `Prototype CI` on the final feature head and require success. +- [ ] Build a clean `gh-pages` tree from current production assets plus the new lab data/app/style/index files; do not copy tests/docs into Pages. +- [ ] Verify Pages deployment success for the exact deploy SHA. +- [ ] Externally fetch root and first M01 lesson to confirm the new scripts/content are publicly served. +- [ ] Open a PR from `feature/m01-learning-lab` to `feature/art-direction-editorial-instrument`; do not merge. diff --git a/docs/superpowers/plans/2026-09-09-m01-playable-vertical-slice-implementation.md b/docs/superpowers/plans/2026-09-09-m01-playable-vertical-slice-implementation.md new file mode 100644 index 0000000..f41a475 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-m01-playable-vertical-slice-implementation.md @@ -0,0 +1,293 @@ +# M01 Playable Vertical Slice Implementation Plan + +> **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:** Implement one deterministic 7–10 minute M01 project-management simulator mission with four decisions, visible project state, optional tools, authored consequences, trajectory review, and cohort-safe evidence persistence. + +**Architecture:** Keep the existing static-app architecture. Add one authored mission data module, one pure deterministic simulator domain module, and one simulator UI extension that owns `#/mission/m01`. Integrate minimally with `index.html`, the base router reservation, M01 course entry point, and M01 validation treatment gate; preserve baseline/post-case isolation and keep all simulator evidence in a dedicated localStorage key. + +**Tech Stack:** Vanilla HTML/CSS/JavaScript, browser localStorage, Node 22 built-in test runner (`node --test`). + +**Spec:** `docs/superpowers/specs/2026-09-09-m01-playable-vertical-slice-design.md` + +## Global Constraints + +- M01 only; no M02 changes. +- No telemetry changes. +- Do not modify `main`; work only on the isolated feature branch. +- No production publication or merge. +- Mission ID is `m01-mission-partner-launch-v1`. +- Simulator storage key is exactly `pm01-sim-m01-v1`. +- Runtime committed decisions are an append-only `decisions[]` array. +- Exactly four deterministic decision moments; no randomness and no hidden aggregate score. +- Visible project state: deadline confidence, stakeholder trust, team capacity, launch risk, each clamped to `0..100`. +- Optional tools are penalty-free and never reveal the preferred answer. +- First committed choice at each decision is immutable for cohort evidence. +- Baseline/post-case remain separate and blind; simulator completion unlocks post-case only for the pinned simulator treatment. +- Do not mix old textual M01.1 treatment evidence with simulator-treatment evidence inside one cohort. +- No automatic mastery inference. +- Accessibility is part of the slice: semantic controls, keyboard path, focus management, text equivalents for meters, reduced-motion compatibility. + +--- + +## File Structure + +- Create `m01-simulator-data.js` — authored mission content, initial state, four decisions, tools, copy, deterministic deltas and flags. +- Create `m01-simulator-domain.js` — pure state/evidence functions: initial state, transition, clamping, completion, trajectory summary. +- Create `m01-simulator-app.js` — route renderer, persistence, tool interaction, decision commit, consequence screen, trajectory review. +- Create `m01-simulator.css` — simulator-only styles and accessibility states. +- Modify `index.html` — load simulator CSS/data/domain/app in deterministic order. +- Modify `app.js` — reserve `mission/m01` from base-router overwrite and route the M01 course entry point to the mission while leaving non-M01 modules unchanged. +- Modify `m01-validation-app.js` — replace the two-lesson learning gate with the pinned simulator mission completion gate on this feature branch; leave baseline/post/field/reflection logic intact. +- Create `tests/m01-simulator-domain.test.js` — pure deterministic transition/evidence tests. +- Create `tests/m01-simulator-integration.test.js` — static/VM contract tests for route ownership, load order, treatment isolation, first-choice lock and accessible markup. + +--- + +### Task 1: Define simulator domain contract with RED tests + +**Files:** +- Create: `tests/m01-simulator-domain.test.js` +- Create: `tests/m01-simulator-integration.test.js` + +**Interfaces:** +- Consumes: existing Node test conventions and browser-global module pattern. +- Produces required interfaces for later tasks: + - `window.PM01SimulatorData.mission` + - `window.PM01SimulatorDomain.initialRun(mission)` + - `window.PM01SimulatorDomain.commitDecision(run, mission, decisionId, optionId, rationale)` + - `window.PM01SimulatorDomain.openTool(run, toolId, decisionId)` + - `window.PM01SimulatorDomain.isComplete(run, mission)` + - `window.PM01SimulatorDomain.trajectory(run, mission)` + +- [x] **Step 1: Write failing pure-domain tests** + +Test that the mission has exactly four decisions, every decision has 3–4 options, all effects are deterministic integers, initial meters are `0..100`, transitions clamp meter values, committed decisions cannot be replaced, tool opening records evidence without changing meters, and completion requires exactly four committed decisions plus required rationales for D1/D4. + +- [x] **Step 2: Write failing integration/static tests** + +Test that `index.html` loads simulator data → domain → app after base learning data but before validation extension; the base router reserves `mission/m01`; the simulator app owns `#/mission/m01`; validation uses the simulator completion contract instead of legacy lesson completion on this branch; simulator storage uses a dedicated key and does not write telemetry. + +- [x] **Step 3: Run tests and confirm RED** + +Run: `node --test tests/m01-simulator-domain.test.js tests/m01-simulator-integration.test.js` + +Observed in CI #147: expected FAIL because simulator files/interfaces/wiring do not yet exist; existing tests remain green. + +- [x] **Step 4: Align RED contract with approved spec before production code** + +Corrected the storage key to `pm01-sim-m01-v1` and the runtime decision contract to append-only `decisions[]` before GREEN implementation. + +--- + +### Task 2: Implement authored mission data + pure transition engine + +**Files:** +- Create: `m01-simulator-data.js` +- Create: `m01-simulator-domain.js` +- Test: `tests/m01-simulator-domain.test.js` + +**Interfaces:** +- `PM01SimulatorData.mission` includes `id`, `version`, `title`, `initialState`, `meters`, `tools`, `decisions`. +- `initialRun(mission)` returns `{ treatmentId, missionVersion, status, decisionIndex, meters, flags, decisions: [], toolsOpened: [], events: [] }`. +- `commitDecision(...)` returns a new run object and rejects an invalid node/option or a second commit to the same node. +- `openTool(...)` returns a new run with one evidence event and unchanged meters. +- `trajectory(...)` returns a deterministic review model from the run; no hidden aggregate score. + +- [ ] **Step 1: Implement mission data exactly from the approved spec** + +Use initial meters: deadline 58, trust 64, capacity 72, risk 63. Implement D1–D4 options, deterministic meter deltas and flags from the spec, plus the three optional tools: Decision Timeline, Hypothesis Comparator, Change Condition. + +- [ ] **Step 2: Implement pure immutable domain functions** + +Clamp meters to `0..100`. Append decision records containing `decisionId`, `optionId`, rationale, state before/after, delta and flags added. Do not use time or randomness in domain decisions; timestamps are UI persistence metadata only. + +- [ ] **Step 3: Run pure-domain tests** + +Run: `node --test tests/m01-simulator-domain.test.js` + +Expected: PASS. + +- [ ] **Step 4: Run full existing test suite** + +Run: `node --test tests/*.test.js` + +Expected: existing tests plus simulator-domain tests pass except integration tests that intentionally wait for Task 3/4 wiring. + +- [ ] **Step 5: Commit** + +Commit message: `feat: add deterministic M01 simulator mission engine` + +--- + +### Task 3: Build accessible playable mission UI and persistence + +**Files:** +- Create: `m01-simulator-app.js` +- Create: `m01-simulator.css` +- Modify: `index.html` +- Test: `tests/m01-simulator-integration.test.js` + +**Interfaces:** +- Route: `#/mission/m01`. +- Dedicated storage key: `pm01-sim-m01-v1`. +- Stored envelope: `{ treatmentId, missionVersion, run, startedAt, completedAt, reviewReachedAt }`. +- Completion evidence must expose the pinned treatment ID `m01-mission-partner-launch-v1` and require the trajectory review to have been reached/persisted. + +- [ ] **Step 1: Load simulator modules and CSS in `index.html`** + +Load `m01-simulator-data.js`, then `m01-simulator-domain.js`, then `m01-simulator-app.js`; keep `m01-validation-app.js` last so it can consume the completion contract. + +- [ ] **Step 2: Render mission shell** + +Render title/premise, textual meter cards, progress `Decision N of 4`, open facts, optional tools and current decision as semantic `
`/radio controls. No canvas-only or color-only state. + +- [ ] **Step 3: Implement tool drawers** + +Each tool is opened by a real `
@@ -49,6 +46,16 @@
+ + + + + + + + + + - + \ No newline at end of file diff --git a/learning-domain.js b/learning-domain.js new file mode 100644 index 0000000..b85d86b --- /dev/null +++ b/learning-domain.js @@ -0,0 +1,81 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.PM01Learning = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function assertScore(score) { + if (!Number.isInteger(score) || score < 0 || score > 3) { + throw new RangeError(`Rubric score must be an integer from 0 to 3; received ${score}`); + } + } + + function scoreAssessment(questions, answers) { + const safeQuestions = Array.isArray(questions) ? questions : []; + const safeAnswers = answers && typeof answers === 'object' ? answers : {}; + const byDimension = {}; + let total = 0; + let answered = 0; + + safeQuestions.forEach((question) => { + const dimension = question.dimension; + if (!dimension) throw new Error(`Question ${question.id || ''} is missing a rubric dimension`); + if (!Object.prototype.hasOwnProperty.call(byDimension, dimension)) byDimension[dimension] = 0; + + const options = Array.isArray(question.options) ? question.options : []; + options.forEach((option) => assertScore(option.score)); + + const selectedId = safeAnswers[question.id]; + if (selectedId == null) return; + const selected = options.find((option) => option.id === selectedId); + if (!selected) throw new Error(`Unknown option ${selectedId} for question ${question.id}`); + + byDimension[dimension] += selected.score; + total += selected.score; + answered += 1; + }); + + return { + total, + max: safeQuestions.length * 3, + byDimension, + answered, + }; + } + + function promotionDecision(baseline, post, options) { + const config = { + minDelta: 3, + minDimensionsImproved: 2, + ...(options || {}), + }; + const baselineTotal = Number(baseline && baseline.total) || 0; + const postTotal = Number(post && post.total) || 0; + const before = (baseline && baseline.byDimension) || {}; + const after = (post && post.byDimension) || {}; + const dimensions = [...new Set([...Object.keys(before), ...Object.keys(after)])]; + const improvedDimensions = dimensions.filter((dimension) => (Number(after[dimension]) || 0) > (Number(before[dimension]) || 0)); + const delta = postTotal - baselineTotal; + + return { + promoted: delta >= config.minDelta && improvedDimensions.length >= config.minDimensionsImproved, + delta, + improvedDimensions, + }; + } + + function deriveLearningState(evidence) { + const safeEvidence = evidence || {}; + if (safeEvidence.studied && safeEvidence.fieldApplied && safeEvidence.transferEvidence) return 'mastered'; + if (safeEvidence.fieldApplied) return 'applied'; + if (safeEvidence.studied) return 'studied'; + return 'unseen'; + } + + return { + scoreAssessment, + promotionDecision, + deriveLearningState, + }; +}); diff --git a/m01-learning-lab-data.js b/m01-learning-lab-data.js new file mode 100644 index 0000000..33b328d --- /dev/null +++ b/m01-learning-lab-data.js @@ -0,0 +1,160 @@ +(function () { + 'use strict'; + + const PM01 = window.PM01; + if (!PM01 || !PM01.m01Validation) return; + + const lessons = PM01.modules.flatMap((module) => module.lessons || []); + + function lesson(id) { + return lessons.find((item) => item.id === id); + } + + function option(id, label, feedback, score) { + return { id, label, feedback, score }; + } + + const projectSystem = lesson('project-system'); + if (projectSystem) { + projectSystem.learningLab = { + skill: 'Сравнивать несколько правдоподобных объяснений проблемы проекта и выбирать системный диагноз по различающим их фактам.', + technique: { + name: 'Seven-flow hypothesis scan', + purpose: 'Быстро перейти от симптома к проверяемой гипотезе о том, какой поток ограничивает результат.', + steps: [ + 'Сформулируй наблюдаемый outcome и 2–4 факта за последние недели — без объяснений и оценок людей.', + 'Просканируй семь потоков и отметь только 2–3, которые реально могут объяснить эти факты.', + 'Сформулируй главный системный диагноз и одну сильную альтернативу, которая тоже согласуется с наблюдениями.', + 'Найди различающий факт: какое наблюдение повысит вероятность одного объяснения и ослабит другое.', + 'Выбери минимальное вмешательство и ранний сигнал. Заранее запиши, при каком факте диагноз нужно пересмотреть.' + ], + model: 'OUTCOME → FACTS → 2–3 SUSPECT FLOWS → HYPOTHESIS ↔ ALTERNATIVE → DISCRIMINATING EVIDENCE → DECISION → EARLY SIGNAL' + }, + workedExample: { + title: 'Пример разбора · внешняя зависимость задержала задачу', + steps: [ + 'Outcome: интеграция должна быть готова к партнерскому тесту в пятницу.', + 'Факты: работа остановилась на три дня; API был внешним; ожидание не отображалось в плане; две другие задачи той же команды не блокировались.', + 'Гипотеза: внешние зависимости не управляются как отдельный поток — нет явного владельца, следующего обязательства и возраста ожидания.', + 'Сильная альтернатива: проблема локальна для этой интеграции — команда поздно обнаружила конкретную техническую несовместимость.', + 'Различающий факт: если у нескольких зависимостей есть длинные периоды ожидания без владельца/следующего шага, системная гипотеза усиливается; если нет — ослабевает.', + 'Решение: сделать активные внешние зависимости явными и измерять возраст ожидания. Ранний сигнал — старые зависимости начинают закрываться раньше сдвига задач.' + ] + }, + drills: [ + { + id: 'm01-drill-system', + lessonId: 'project-system', + stage: 'cold', + required: true, + title: 'Cold decision · Красная задача', + situation: 'Интеграционная задача просрочена на пять дней. Разработчик три дня ждал API внешней команды. Внешняя команда говорит, что базовый endpoint был известен заранее, но нужный scope доступа согласовали только после запроса разработчика. В плане ожидание отдельно не отображалось.', + prompt: 'Какой первый ход даст самый сильный системный диагноз, а не просто уменьшит риск следующей просрочки?', + options: [ + option('historical-buffer', 'Добавить к похожим интеграциям резерв на основе исторического времени ожидания внешних команд', 'Это разумно улучшает прогноз, но может лишь встроить неизвестный механизм ожидания в оценку, не объясняя его.', 1), + option('dependency-owner', 'Для каждой внешней зависимости сразу назначать владельца, дату следующего обязательства и правило эскалации', 'Это сильное управленческое действие и может сократить ожидание, но оно уже предполагает, что главный механизм найден.', 2), + option('blocker-discipline', 'Требовать фиксировать внешний blocker в день обнаружения и поднимать его на следующем daily', 'Это улучшит видимость и скорость реакции, но оставляет открытым вопрос, где именно возникает системное ожидание: в обнаружении, решении, доступе или передаче.', 2), + option('dependency-timeline', 'Восстановить timeline нескольких внешних зависимостей: когда возник запрос, кто владел следующим шагом, сколько ждали и что разблокировало работу; затем выбрать общий механизм', 'Такой ход различает конкурирующие объяснения и позволяет вмешиваться в повторяемый механизм, а не в единичный симптом.', 3) + ] + }, + { + id: 'm01-exit-system', + lessonId: 'project-system', + stage: 'exit', + required: true, + title: 'Exit check · Зеленый статус, красный результат', + situation: 'У проекта 92% задач закрываются в срок, но запуск дважды переносился. Юридические вопросы обычно появляются за 10–14 дней до контрольной даты, проходят через продуктового менеджера и юриста, а финальные ограничения часто фиксируются за 1–2 дня до даты. Иногда задержка вызвана отсутствием ответа бизнеса, иногда — новой трактовкой юриста.', + prompt: 'Что лучше сделать первым, если гипотеза — проект ограничен потоком решений, а не исполнением задач?', + options: [ + option('schedule-contingency', 'Добавить общий contingency к плану запуска на основе двух прошлых переносов', 'Это делает прогноз реалистичнее, но не проверяет, почему критичные решения закрываются поздно.', 1), + option('legal-sla', 'Сразу ввести SLA: юридический ответ не позднее чем через два рабочих дня', 'SLA может стать полезным вмешательством, но пока смешивает разные причины задержки и предполагает, что именно скорость юриста является узким местом.', 2), + option('decision-review', 'Проводить еженедельный review всех открытых юридических вопросов с бизнесом и юристом', 'Review повышает частоту внимания и может сократить возраст решений, но не покажет, какой участок пути реально создаёт задержку.', 2), + option('decision-timeline', 'Взять последние 8–10 критичных вопросов и восстановить путь вопрос → владелец следующего шага → ожидание → решение → downstream-переделка; затем поставить порог на найденное узкое место', 'Это сначала проверяет механизм и только потом задаёт интервенцию там, где накапливается возраст решения.', 3) + ] + } + ], + workbookTitle: 'Карта системной гипотезы', + workbookFields: [ + { id: 'outcome', label: 'Outcome', prompt: 'Какой наблюдаемый результат проекта должен измениться и для кого?', required: true }, + { id: 'evidence', label: 'Факты и подозрительные потоки', prompt: 'Запиши 2–4 наблюдаемых факта и назови только 2–3 потока, которые реально могут их объяснить.', required: true }, + { id: 'hypothesis', label: 'Главная гипотеза', prompt: 'Какой один системный механизм лучше всего связывает эти факты?', required: true }, + { id: 'alternative', label: 'Сильная альтернатива', prompt: 'Какое другое правдоподобное объяснение подходит тем же фактам?', required: true }, + { id: 'falsifier', label: 'Факт-опровержение', prompt: 'Какой конкретный факт заставит ослабить или отбросить главную гипотезу в пользу альтернативы?', required: true }, + { id: 'decision', label: 'Решение и ранний сигнал', prompt: 'Какое минимальное вмешательство ты выберешь сейчас и какой ранний сигнал должен измениться, если диагноз верный?', required: true } + ], + transferPrompt: 'Возьми одно реальное решение по текущему проекту. Запиши главную гипотезу и сильную альтернативу, собери evidence, который различает их, и прими одно минимальное действие. Заранее укажи факт, при котором ты изменишь или отменишь это решение.' + }; + } + + const systemDiagnostic = lesson('system-diagnostic'); + if (systemDiagnostic) { + systemDiagnostic.learningLab = { + skill: 'Строить причинную цепь как проверяемую гипотезу: объяснять повторяемость, сравнивать альтернативы и заранее искать факт, который способен опровергнуть диагноз.', + technique: { + name: 'Falsifiable causal chain', + purpose: 'Не останавливаться на убедительной истории — проверять, действительно ли выбранный механизм воспроизводит симптом.', + steps: [ + 'Отдели симптом от объяснения и собери 2–4 наблюдаемых факта: что, когда и при каких условиях происходило.', + 'Построй цепь механизм → системное условие: почему тот же эффект повторится с другим человеком или в следующем цикле.', + 'Сформулируй сильную альтернативную цепь, которая тоже объясняет факты, но ведёт к другому вмешательству.', + 'Назови факт-опровержение: что должно быть правдой, чтобы твой основной диагноз оказался слабее альтернативы.', + 'Выбери минимальное вмешательство в системное условие, ранний сигнал и момент пересмотра решения.' + ], + model: 'SYMPTOM + FACTS → CAUSAL HYPOTHESIS ↔ ALTERNATIVE → FALSIFIER → SYSTEM CONDITION → INTERVENTION → EARLY SIGNAL' + }, + workedExample: { + title: 'Пример разбора · функция трижды возвращается из QA', + steps: [ + 'Симптом и факты: три возврата; каждый связан с иной трактовкой одного бизнес-правила; дефекты реализации между возвратами не повторяются.', + 'Главная гипотеза: правило не имеет единственного проверяемого решения до старта разработки, поэтому разные участники легитимно интерпретируют его по-разному.', + 'Сильная альтернатива: правило достаточно определено, а разработчик системно пропускает явные ограничения в спецификации.', + 'Факт-опровержение: если до разработки уже существовали однозначное правило, владелец и примеры, а возвраты вызваны несоблюдением этих примеров, основной диагноз ослабевает.', + 'Вмешательство при подтверждении гипотезы: один owner трактовки + исполнимый пример ожидаемого поведения до разработки.', + 'Ранний сигнал: число новых трактовок и уточнений после старта реализации падает раньше, чем общий defect rate.' + ] + }, + drills: [ + { + id: 'm01-drill-diagnostic', + lessonId: 'system-diagnostic', + stage: 'cold', + required: true, + title: 'Cold decision · Дефект в QA', + situation: 'QA три раза возвращает одну и ту же функцию из-за разных трактовок бизнес-правила. Требование согласовывали в переписке продуктовый менеджер, аналитик и бизнес. В задаче есть текстовое описание, но нет примеров пограничного поведения. Разработчик утверждает, что каждый раз реализовывал последнюю известную трактовку.', + prompt: 'Какой первый ход лучше различит несколько правдоподобных причин повторных возвратов?', + options: [ + option('acceptance-example', 'До следующей реализации зафиксировать один набор acceptance examples и назначить владельца финальной трактовки', 'Это сильное профилактическое действие, но оно сразу принимает гипотезу о неоднозначном upstream-решении как основную.', 2), + option('joint-refinement', 'Проводить обязательный refinement разработчика и QA для всех правил с пограничными условиями', 'Это может снизить расхождения за счёт общей картины, но не различает отсутствие решения и плохое применение уже существующего решения.', 2), + option('regression-checklist', 'Расширить QA checklist всеми тремя обнаруженными трактовками и проверять их в следующем цикле', 'Это уменьшит шанс повторить известные варианты, но лечит downstream-обнаружение и почти не объясняет происхождение новых трактовок.', 1), + option('return-timeline', 'Разобрать три возврата: какая трактовка считалась действующей до разработки, кто имел право её менять, где это было зафиксировано и что именно изменилось; затем выбрать механизм', 'Так можно отличить неоднозначный decision interface от ошибок применения уже определённого правила и выбрать разное вмешательство.', 3) + ] + }, + { + id: 'm01-exit-diagnostic', + lessonId: 'system-diagnostic', + stage: 'exit', + required: true, + title: 'Exit check · Команда постоянно «не успевает»', + situation: 'Каждую вторую неделю разработчики не заканчивают запланированное. За последние четыре недели 11 из 18 задач хотя бы раз блокировались ожиданием ответа, доступа или согласования. Часть blockers закрывалась за часы после эскалации, часть жила несколько дней; оценки задач при этом в среднем отличаются от факта примерно на 15%.', + prompt: 'Какой следующий шаг сильнее всего проверит причинный диагноз до изменения процесса?', + options: [ + option('estimate-factor', 'Добавить к оценкам коэффициент на историческую долю blocker-time', 'Это может улучшить планирование срока, но превращает ожидание в резерв и не объясняет, какие blockers являются системным механизмом.', 1), + option('wip-cap', 'Снизить WIP и брать следующую работу, когда задача блокируется внешним ожиданием', 'Это уменьшает потерю мощности и является разумным near-miss, но может замаскировать накопление внешних решений вместо устранения причины.', 2), + option('blocker-owner', 'Назначить дежурного владельца, который эскалирует любой blocker старше четырёх часов', 'Такой механизм может быстро сократить часть ожиданий, но одинаково обрабатывает доступ, решение и согласование без проверки их разных причин.', 2), + option('blocker-sample', 'Разобрать 11 заблокированных задач: тип ожидания, источник, возраст до/после эскалации и что реально сняло blocker; проверить, создаёт ли небольшое число повторяемых условий большую часть задержки', 'Это проверяет повторяемость механизма и даёт основание выбрать точечное системное вмешательство вместо универсального правила.', 3) + ] + } + ], + workbookTitle: 'Проверяемая причинная цепь', + workbookFields: [ + { id: 'evidence', label: 'Симптом и факты', prompt: 'Опиши симптом без имён и оценок, затем добавь 2–4 наблюдаемых факта: что происходило и при каких условиях.', required: true }, + { id: 'mechanism', label: 'Причинный механизм', prompt: 'Какая последовательность событий связывает факты с симптомом?', required: true }, + { id: 'condition', label: 'Системное условие', prompt: 'Какое условие делает механизм повторяемым с другим человеком или в следующем цикле?', required: true }, + { id: 'alternative', label: 'Сильная альтернатива', prompt: 'Какая другая причинная цепь объясняет те же факты и потребовала бы другого вмешательства?', required: true }, + { id: 'falsifier', label: 'Факт-опровержение', prompt: 'Какой факт покажет, что основная цепь слабее альтернативной?', required: true }, + { id: 'intervention', label: 'Вмешательство и ранний сигнал', prompt: 'Какое минимальное изменение системного условия ты проверишь и какой ранний сигнал должен измениться до итогового результата?', required: true } + ], + transferPrompt: 'Выбери одно реальное решение по повторяющейся проблеме проекта. Построй причинную гипотезу и сильную альтернативу, найди evidence и факт, способный опровергнуть основной диагноз, затем выбери минимальное вмешательство. Заранее запиши, при каком наблюдении ты изменишь или отменишь решение.' + }; + } +})(); diff --git a/m01-simulator-app.js b/m01-simulator-app.js new file mode 100644 index 0000000..9f6e26c --- /dev/null +++ b/m01-simulator-app.js @@ -0,0 +1,315 @@ +(function () { + 'use strict'; + + const MISSION = window.PM01SimulatorData && window.PM01SimulatorData.mission; + const DOMAIN = window.PM01SimulatorDomain; + const storageKey = 'pm01-sim-m01-v1'; + const missionRoute = 'mission/m01'; + + if (!MISSION || !DOMAIN) return; + + let envelope = null; + let versionBlocked = false; + let storageHealthy = testStorage(); + let openToolId = null; + + function now() { + return new Date().toISOString(); + } + + function currentRoute() { + return location.hash.replace(/^#\/?/, '').replace(/\/$/, ''); + } + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function testStorage() { + const probe = `${storageKey}-probe`; + try { + localStorage.setItem(probe, '1'); + localStorage.removeItem(probe); + return true; + } catch (_) { + return false; + } + } + + function readEnvelope() { + try { + const parsed = JSON.parse(localStorage.getItem(storageKey)); + if (!parsed) return null; + if (parsed.treatmentId !== MISSION.id || parsed.missionVersion !== MISSION.version) { + versionBlocked = true; + return parsed; + } + return parsed; + } catch (_) { + return null; + } + } + + function saveEnvelope(next) { + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + storageHealthy = true; + envelope = next; + return true; + } catch (_) { + storageHealthy = false; + renderMission('Не удалось сохранить данные миссии. Не продолжай: evidence может быть потерян.'); + return false; + } + } + + function createEnvelope() { + return { + treatmentId: MISSION.id, + missionVersion: MISSION.version, + run: DOMAIN.initialRun(MISSION), + screen: 'decision', + drafts: {}, + startedAt: now(), + completedAt: null, + reviewReachedAt: null, + }; + } + + function meterDescriptor(meter, value) { + if (meter.id === 'risk') { + if (value <= 30) return 'низкий'; + if (value <= 60) return 'средний'; + return 'высокий'; + } + if (value < 40) return 'низко'; + if (value < 70) return 'средне'; + return 'высоко'; + } + + function meterGrid(meters) { + return `
${MISSION.meters.map((meter) => { + const value = meters[meter.id]; + const direction = meter.higherIsBetter ? 'выше обычно лучше' : 'ниже обычно лучше'; + return `
+ ${escapeHtml(meter.label)} + ${value}/100 + + ${escapeHtml(meterDescriptor(meter, value))} +
`; + }).join('')}
`; + } + + function toolsBlock(decisionId) { + return `
+

По запросу

Инструменты

Открытие инструмента не штрафует состояние проекта и не подсказывает вариант решения.

+
${MISSION.tools.map((tool) => ``).join('')}
+ ${openToolId ? toolDrawer(openToolId, decisionId) : ''} +
`; + } + + function toolDrawer(toolId, decisionId) { + const tool = MISSION.tools.find((item) => item.id === toolId); + if (!tool) return ''; + return ``; + } + + function decisionSituation(decision, run) { + const conditional = decision.contextualSituation; + if (!conditional) return decision.situation; + return run.flags[conditional.flag] ? conditional.whenTrue : conditional.whenFalse; + } + + function decisionView() { + const run = envelope.run; + const decision = MISSION.decisions[run.decisionIndex]; + if (!decision) return reviewView(); + const draft = envelope.drafts[decision.id] || ''; + return `
+
+

M01 · Mission · решение ${run.decisionIndex + 1}/${MISSION.decisions.length}

${escapeHtml(decision.title)}

+ ${run.decisionIndex + 1} / ${MISSION.decisions.length} +
+ ${meterGrid(run.meters)} +

Ситуация

${escapeHtml(decisionSituation(decision, run))}

+ ${toolsBlock(decision.id)} +
+
+
+ ${escapeHtml(decision.prompt)} +
${decision.options.map((option) => ``).join('')}
+
+ ${decision.requiredRationale ? `` : ''} +
Вернуться к validation
+
+
+
+
`; + } + + function consequenceView() { + const run = envelope.run; + const record = run.decisions[run.decisions.length - 1]; + const decision = MISSION.decisions.find((item) => item.id === record.decisionId); + const option = decision.options.find((item) => item.id === record.optionId); + const rows = MISSION.meters.map((meter) => { + const before = record.stateBefore[meter.id]; + const after = record.stateAfter[meter.id]; + const change = record.delta[meter.id]; + const sign = change > 0 ? `+${change}` : String(change); + return `
  • ${escapeHtml(meter.label)}${before} → ${after} (${sign})
  • `; + }).join(''); + return `
    +

    Последствие · ${escapeHtml(decision.id.toUpperCase())}

    Решение изменило проект

    + ${meterGrid(run.meters)} +
    +

    ${escapeHtml(option.label)}

    +

    ${escapeHtml(option.consequence)}

    +
      ${rows}
    +
    +
    +
    +
    `; + } + + function reviewView() { + const path = DOMAIN.trajectory(envelope.run, MISSION); + const decisionRows = path.decisions.map((record, index) => { + const decision = MISSION.decisions.find((item) => item.id === record.decisionId); + const option = decision.options.find((item) => item.id === record.optionId); + return `
  • D${index + 1}
    ${escapeHtml(option.label)}${record.rationale ? `

    ${escapeHtml(record.rationale)}

    ` : ''}
  • `; + }).join(''); + const toolNames = [...new Set(path.toolsOpened.map((event) => event.toolId))].map((toolId) => MISSION.tools.find((tool) => tool.id === toolId)?.title || toolId); + return `
    +

    Траектория завершена

    ${escapeHtml(MISSION.finalReview.title)}

    Посмотри на свои решения и изменение состояния. Здесь нет оценки или эталонного пути до post-case.

    + ${meterGrid(path.finalMeters)} +
    +

    Твои решения

      ${decisionRows}
    + +
    +

    Перед post-case

      ${MISSION.finalReview.prompts.map((prompt) => `
    • ${escapeHtml(prompt)}
    • `).join('')}
    + +
    +
    `; + } + + function briefingView() { + return `
    +

    M01 · Playable mission

    ${escapeHtml(MISSION.title)}

    ${escapeHtml(MISSION.premise)}

    + ${meterGrid(MISSION.initialState)} +

    Твоя роль

    Ты — lead проекта. Последствия решений сохраняются. Задача — не максимизировать один показатель, а управлять неопределённостью, trade-offs и новым evidence.

    Инструменты доступны в каждом решении и не влияют на показатели сами по себе.

    +
    Назад
    +
    +
    `; + } + + function blockedView() { + return `
    `; + } + + function renderMission(message) { + if (currentRoute() !== missionRoute) return; + const main = document.querySelector('#main'); + if (!main) return; + if (versionBlocked) main.innerHTML = blockedView(); + else if (!envelope) main.innerHTML = briefingView(); + else if (envelope.screen === 'consequence') main.innerHTML = consequenceView(); + else if (DOMAIN.isComplete(envelope.run, MISSION)) { + if (!envelope.reviewReachedAt) { + const next = { ...envelope, screen: 'review', reviewReachedAt: now(), completedAt: now() }; + if (!saveEnvelope(next)) return; + } + main.innerHTML = reviewView(); + } else main.innerHTML = decisionView(); + + bindEvents(); + const messageTarget = document.querySelector('#sim-message'); + if (message && messageTarget) messageTarget.textContent = message; + document.querySelector('#sim-focus-target')?.focus(); + document.querySelectorAll('.main-nav a').forEach((link) => link.classList.toggle('active', link.dataset.route === 'course')); + window.scrollTo(0, 0); + } + + function bindEvents() { + document.querySelector('#sim-start')?.addEventListener('click', () => { + if (!storageHealthy) { + renderMission('LocalStorage недоступен. Миссию нельзя начать без надёжного сохранения evidence.'); + return; + } + saveEnvelope(createEnvelope()); + renderMission(); + }); + + document.querySelectorAll('[data-open-tool]').forEach((button) => button.addEventListener('click', () => { + const toolId = button.dataset.openTool; + const decision = envelope && MISSION.decisions[envelope.run.decisionIndex]; + if (!decision) return; + if (openToolId === toolId) { + openToolId = null; + renderMission(); + return; + } + const nextRun = DOMAIN.openTool(envelope.run, toolId, decision.id); + const next = { ...envelope, run: nextRun }; + if (!saveEnvelope(next)) return; + openToolId = toolId; + renderMission(); + })); + + document.querySelector('[data-close-tool]')?.addEventListener('click', () => { + openToolId = null; + renderMission(); + }); + + document.querySelector('[data-rationale]')?.addEventListener('input', (event) => { + if (!envelope) return; + const next = { ...envelope, drafts: { ...envelope.drafts, [event.target.dataset.rationale]: event.target.value } }; + saveEnvelope(next); + }); + + document.querySelector('#sim-decision-form')?.addEventListener('submit', (event) => { + event.preventDefault(); + const decision = MISSION.decisions[envelope.run.decisionIndex]; + const selected = document.querySelector('input[name="sim-choice"]:checked'); + const rationale = document.querySelector('[data-rationale]')?.value || ''; + if (!selected) { + renderMission('Выбери один вариант перед фиксацией решения.'); + return; + } + try { + const nextRun = DOMAIN.commitDecision(envelope.run, MISSION, decision.id, selected.value, rationale); + const next = { ...envelope, run: nextRun, screen: 'consequence', drafts: { ...envelope.drafts, [decision.id]: rationale } }; + if (!saveEnvelope(next)) return; + openToolId = null; + renderMission(); + } catch (error) { + renderMission(error.message); + } + }); + + document.querySelector('#sim-continue')?.addEventListener('click', () => { + const next = { ...envelope, screen: DOMAIN.isComplete(envelope.run, MISSION) ? 'review' : 'decision' }; + if (!saveEnvelope(next)) return; + renderMission(); + }); + } + + function renderExtension() { + if (currentRoute() !== missionRoute) return; + versionBlocked = false; + envelope = readEnvelope(); + renderMission(); + } + + window.addEventListener('hashchange', () => queueMicrotask(renderExtension)); + renderExtension(); +})(); diff --git a/m01-simulator-data.js b/m01-simulator-data.js new file mode 100644 index 0000000..867cd0d --- /dev/null +++ b/m01-simulator-data.js @@ -0,0 +1,136 @@ +(function () { + 'use strict'; + + function option(id, label, consequence, effects, flags) { + return { id, label, consequence, effects, flags: flags || [] }; + } + + const mission = { + id: 'm01-mission-partner-launch-v1', + version: 1, + title: 'Пять дней до партнёрского запуска', + estimatedMinutes: '7–10', + premise: 'Ты ведёшь партнёрскую интеграцию. До публичного запуска пять рабочих дней: код почти готов, но финальный формат данных, юридическое ограничение и несколько открытых решений всё ещё могут создать позднюю переделку.', + initialState: { deadline: 58, trust: 64, capacity: 72, risk: 63 }, + meters: [ + { id: 'deadline', label: 'Уверенность в сроке', higherIsBetter: true, description: 'Насколько реалистично запустить обещанный объём в текущую дату.' }, + { id: 'trust', label: 'Доверие участников', higherIsBetter: true, description: 'Насколько команда, партнёр и владельцы решений доверяют текущему способу управления.' }, + { id: 'capacity', label: 'Ресурс команды', higherIsBetter: true, description: 'Практическая доступная ёмкость без перегрузки.' }, + { id: 'risk', label: 'Риск запуска', higherIsBetter: false, description: 'Незакрытая экспозиция, способная вызвать позднюю переделку или срыв; ниже лучше.' }, + ], + tools: [ + { + id: 'decision-timeline', + title: 'Decision Timeline', + purpose: 'Разобрать, где именно стареет решение, прежде чем вмешиваться.', + items: [ + 'Какое решение действительно открыто?', + 'Когда оно стало необходимо?', + 'Кто владеет следующим обязательством?', + 'Что ждёт этого обязательства?', + 'Сколько уже длится ожидание?', + 'Что станет дорогим, если решение придёт позже?', + ], + }, + { + id: 'hypothesis-comparator', + title: 'Hypothesis Comparator', + purpose: 'Сравнить основную гипотезу с сильной альтернативой и найти различающий факт.', + items: [ + 'Каково предпочитаемое объяснение?', + 'Какая сильная альтернатива тоже объясняет факты?', + 'Какие факты объясняют обе версии?', + 'Какое наблюдение повысит вероятность одной версии относительно другой?', + 'Какой факт ослабит предпочитаемое объяснение?', + ], + }, + { + id: 'change-condition', + title: 'Change Condition', + purpose: 'Заранее задать ранний сигнал и условие пересмотра решения.', + items: [ + 'Что должно измениться первым, если решение верно?', + 'К какому моменту?', + 'Какое наблюдение означает «продолжать»?', + 'Какое наблюдение означает «пересмотреть или остановить»?', + ], + }, + ], + decisions: [ + { + id: 'd1', + stage: 'diagnose', + title: 'Пять дней до запуска', + situation: 'QA спрашивает, будет ли завтрашняя сборка стабильной. Партнёр предупреждает: финальный формат данных ещё может измениться из-за юридической проверки одного поля. Команда ждёт твоего решения.', + prompt: 'Что ты сделаешь первым?', + rationalePrompt: 'Почему именно это — первый ход?', + requiredRationale: true, + options: [ + option('contingency', 'Добавить contingency к графику.', 'Прогноз становится осторожнее, но источник неопределённости остаётся неразличённым.', { deadline: 5, trust: 0, capacity: 0, risk: -2 }, ['buffer_added']), + option('prepare-both', 'Попросить QA готовиться к обоим форматам.', 'Локальный риск снижается, но команда тратит ресурс на параллельные варианты, пока upstream-решение остаётся открытым.', { deadline: 2, trust: 1, capacity: -12, risk: -4 }, ['parallel_variants']), + option('hard-deadline', 'Поставить партнёру жёсткий срок финального ответа.', 'Появляется давление на срок, но неясный владелец решения всё ещё может сохранить очередь ожидания.', { deadline: 4, trust: -6, capacity: 0, risk: -3 }, ['external_pressure']), + option('decision-timeline', 'Восстановить timeline критичного решения и определить владельца следующего необратимого шага.', 'Ты различаешь ожидание информации, владельца, согласование и downstream-передачу до выбора вмешательства.', { deadline: 3, trust: 2, capacity: -2, risk: -8 }, ['timeline_reconstructed', 'evidence_requested']), + ], + }, + { + id: 'd2', + stage: 'intervene', + title: 'Решение было, но не стало обязательством', + situation: 'Выясняется: технический владелец партнёра уже выбрал предпочтительный формат, но его не зафиксировали как финальный — product manager ждал подтверждения бизнеса.', + contextualSituation: { + flag: 'timeline_reconstructed', + whenTrue: 'Timeline показывает: технический выбор уже существовал, но завис между владельцами и не превратился в обязательство.', + whenFalse: 'Поздняя эскалация партнёра показывает: технический выбор уже существовал, но завис между владельцами и не превратился в обязательство.', + }, + prompt: 'Какое вмешательство ты сделаешь сейчас?', + requiredRationale: false, + options: [ + option('add-developer', 'Добавить ещё одного разработчика для защиты запуска.', 'Локальной мощности становится больше, но интерфейс решения не меняется и появляется цена координации.', { deadline: 5, trust: 0, capacity: 6, risk: 1 }, ['extra_capacity', 'coordination_cost']), + option('freeze-now', 'Немедленно зафиксировать текущий предпочтительный формат.', 'Вариативность падает быстро, но проект принимает риск преждевременно закрепить неверное бизнес- или юридическое решение.', { deadline: 9, trust: -5, capacity: 2, risk: 5 }, ['premature_freeze']), + option('decision-contract', 'Назначить одного владельца решения, срок его действия и проверяемый acceptance example.', 'Решение получает явный интерфейс и перестаёт оставаться неформальным ожиданием между несколькими владельцами.', { deadline: 7, trust: 6, capacity: -2, risk: -12 }, ['decision_contract']), + option('keep-variants', 'Держать оба технических варианта до полного согласия всех сторон.', 'Гибкость сохраняется, но команда платит за опциональность доступной ёмкостью.', { deadline: 3, trust: 2, capacity: -14, risk: -3 }, ['optionality_preserved']), + ], + }, + { + id: 'd3', + stage: 'tradeoff', + title: 'Три дня: новый запрос бизнеса', + situation: 'Бизнес просит добавить ещё одно поле партнёра к запуску, чтобы избежать ручной операции после go-live. QA предупреждает: это расширяет тестовую поверхность.', + prompt: 'Какой trade-off ты выбираешь?', + requiredRationale: false, + options: [ + option('absorb-field', 'Принять дополнительное поле и попросить команду вместить его.', 'Объём ценности растёт сейчас, но поздний scope съедает ресурс и увеличивает риск переделки.', { deadline: -8, trust: 3, capacity: -14, risk: 11 }, ['late_scope_added']), + option('reject-field', 'Отказать в поле, потому что дата фиксирована.', 'Дата защищена, но стоимость ручного workaround не входит в решение и доверие бизнеса снижается.', { deadline: 7, trust: -8, capacity: 3, risk: -3 }, ['scope_rejected']), + option('split-decision', 'Отделить необратимое требование запуска от обратимого улучшения и вынести поле в явное follow-up решение.', 'Проект сохраняет обязательный контракт, а стоимость опциональности становится явной и ограниченной.', { deadline: 6, trust: 5, capacity: -2, risk: -8 }, ['reversible_scope_split']), + option('move-launch', 'Перенести весь запуск, чтобы включить всё вместе.', 'Немедленный риск качества уменьшается, но проект тратит обещанную дату, не проверив критичность нового поля.', { deadline: -18, trust: -3, capacity: 8, risk: -10 }, ['launch_moved']), + ], + }, + { + id: 'd4', + stage: 'revise', + title: 'Один день: факт, который меняет картину', + situation: 'Формат данных теперь однозначно зафиксирован. Оставшийся blocker — правило безопасности, обнаруженное внутри уже одобренной зависимости. Этот факт ослабляет простую версию прежнего диагноза.', + invariantEvidence: 'Формат решения закрыт; оставшийся blocker — правило безопасности внутри уже одобренной зависимости.', + prompt: 'Что ты делаешь со своим текущим диагнозом?', + rationalePrompt: 'Какой факт заставил тебя сохранить или пересмотреть диагноз?', + requiredRationale: true, + options: [ + option('stay-course', 'Остаться с исходным планом: менять диагноз так поздно опаснее.', 'Последовательность плана сохраняется, но новый опровергающий факт не меняет модель и риск поздней ошибки растёт.', { deadline: 2, trust: -4, capacity: -5, risk: 12 }, ['ignored_falsifier']), + option('blame-escalate', 'Эскалировать команду за то, что проблема не была найдена раньше.', 'Давление переносится на людей, а механизм обнаружения и управления зависимостью остаётся без нового диагноза.', { deadline: 0, trust: -10, capacity: -3, risk: 5 }, ['person_blame']), + option('revise-diagnosis', 'Пересмотреть диагноз, изолировать security-зависимость и определить минимальный безопасный запуск.', 'Ты принимаешь falsifier: меняешь модель проблемы и ограничиваешь решение новым подтверждённым механизмом.', { deadline: -2, trust: 8, capacity: 1, risk: -18 }, ['hypothesis_revised', 'safe_scope']), + option('add-capacity', 'Добавить ресурс и оставить исходный диагноз и scope без изменений.', 'Команда получает дополнительную мощность, но она компенсирует симптом без пересмотра объяснения.', { deadline: 4, trust: -2, capacity: 4, risk: 7 }, ['capacity_compensation']), + ], + }, + ], + finalReview: { + title: 'Разбор твоей траектории', + prompts: [ + 'Где изменилась твоя модель проблемы?', + 'Какой trade-off ты сознательно принял?', + 'Какой evidence заставил бы тебя действовать иначе в следующий раз?', + ], + }, + }; + + window.PM01SimulatorData = { mission }; +})(); diff --git a/m01-simulator-domain.js b/m01-simulator-domain.js new file mode 100644 index 0000000..6da769a --- /dev/null +++ b/m01-simulator-domain.js @@ -0,0 +1,116 @@ +(function () { + 'use strict'; + + const MIN_RATIONALE_LENGTH = 8; + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function clamp(value) { + return Math.max(0, Math.min(100, Number(value))); + } + + function findDecision(mission, decisionId) { + return mission.decisions.find((decision) => decision.id === decisionId); + } + + function findOption(decision, optionId) { + return decision && decision.options.find((option) => option.id === optionId); + } + + function rationaleValid(value) { + return String(value || '').trim().length >= MIN_RATIONALE_LENGTH; + } + + function initialRun(mission) { + return { + treatmentId: mission.id, + missionVersion: mission.version, + status: 'in_progress', + decisionIndex: 0, + meters: clone(mission.initialState), + flags: {}, + decisions: [], + toolsOpened: [], + events: [], + }; + } + + function commitDecision(run, mission, decisionId, optionId, rationale) { + if (!run || run.treatmentId !== mission.id || run.missionVersion !== mission.version) throw new Error('Mission treatment mismatch'); + if (run.decisions.some((decision) => decision.decisionId === decisionId)) throw new Error(`Decision ${decisionId} already committed`); + + const expected = mission.decisions[run.decisionIndex]; + if (!expected || expected.id !== decisionId) throw new Error(`Decision ${decisionId} is out of order`); + const decision = findDecision(mission, decisionId); + const option = findOption(decision, optionId); + if (!option) throw new Error(`Unknown option ${optionId}`); + const cleanRationale = String(rationale || '').trim(); + if (decision.requiredRationale && !rationaleValid(cleanRationale)) { + throw new Error(`Decision ${decisionId} requires rationale of at least ${MIN_RATIONALE_LENGTH} characters`); + } + + const next = clone(run); + const before = clone(next.meters); + const delta = {}; + Object.keys(next.meters).forEach((meterId) => { + const change = Number(option.effects[meterId] || 0); + delta[meterId] = change; + next.meters[meterId] = clamp(next.meters[meterId] + change); + }); + (option.flags || []).forEach((flag) => { next.flags[flag] = true; }); + + next.decisions.push({ + decisionId, + optionId, + rationale: cleanRationale, + stateBefore: before, + stateAfter: clone(next.meters), + delta, + flagsAdded: [...(option.flags || [])], + }); + next.events.push({ type: 'decision_committed', decisionId, optionId }); + next.decisionIndex += 1; + if (next.decisionIndex >= mission.decisions.length) next.status = 'decisions_complete'; + return next; + } + + function openTool(run, toolId, decisionId) { + if (!String(toolId || '').trim()) throw new Error('Tool id is required'); + const next = clone(run); + next.toolsOpened.push({ toolId, decisionId }); + next.events.push({ type: 'tool_opened', toolId, decisionId }); + return next; + } + + function isComplete(run, mission) { + if (!run || run.treatmentId !== mission.id || run.missionVersion !== mission.version) return false; + if (run.decisions.length !== mission.decisions.length) return false; + return mission.decisions.every((decision) => { + const committed = run.decisions.find((item) => item.decisionId === decision.id); + return Boolean(committed) && (!decision.requiredRationale || rationaleValid(committed.rationale)); + }); + } + + function trajectory(run, mission) { + return { + treatmentId: run.treatmentId, + missionVersion: run.missionVersion, + decisions: clone(run.decisions), + toolsOpened: clone(run.toolsOpened), + flags: clone(run.flags), + diagnosisRevised: Boolean(run.flags.hypothesis_revised), + meterHistory: [clone(mission.initialState), ...run.decisions.map((decision) => clone(decision.stateAfter))], + finalMeters: clone(run.meters), + }; + } + + window.PM01SimulatorDomain = { + initialRun, + commitDecision, + openTool, + isComplete, + trajectory, + }; +})(); diff --git a/m01-simulator-routing.js b/m01-simulator-routing.js new file mode 100644 index 0000000..5a9e6fd --- /dev/null +++ b/m01-simulator-routing.js @@ -0,0 +1,50 @@ +(function () { + 'use strict'; + + const missionHref = '#/mission/m01'; + + function currentRoute() { + return location.hash.replace(/^#\/?/, '').replace(/\/$/, ''); + } + + function rewriteCourseEntries() { + const route = currentRoute(); + if (!['home', 'course', ''].includes(route)) return; + document.querySelectorAll('a[href="#/lesson/project-system"], a[href="#/lesson/system-diagnostic"]').forEach((link) => { + link.setAttribute('href', missionHref); + link.dataset.m01MissionEntry = 'true'; + }); + } + + function rewriteValidationLearningStep() { + if (currentRoute() !== 'validation/m01') return; + const links = [...document.querySelectorAll('a[href="#/lesson/project-system"], a[href="#/lesson/system-diagnostic"]')]; + if (!links.length) return; + + const primary = links[0]; + primary.setAttribute('href', missionHref); + primary.textContent = 'Открыть M01 playable mission →'; + primary.dataset.m01MissionEntry = 'true'; + links.slice(1).forEach((link) => { link.hidden = true; }); + + const step = primary.closest('.validation-step'); + const heading = step && step.querySelector('h2'); + const description = step && step.querySelector('header p:last-child'); + const state = step && step.querySelector('.validation-state'); + if (heading) heading.textContent = 'Пройди M01 playable mission'; + if (description) description.textContent = 'Четыре решения фиксируют твою траекторию. Вернись сюда после финального разбора — тогда откроется post-case.'; + if (state) { + const complete = Boolean(window.PM01SimulatorGate && window.PM01SimulatorGate.isComplete()); + state.textContent = `Миссия: ${complete ? 'завершена ✓' : 'нужно пройти до финального разбора'}`; + } + } + + function wire() { + rewriteCourseEntries(); + rewriteValidationLearningStep(); + } + + window.addEventListener('hashchange', () => queueMicrotask(wire)); + window.addEventListener('DOMContentLoaded', () => queueMicrotask(wire)); + queueMicrotask(wire); +})(); diff --git a/m01-simulator.css b/m01-simulator.css new file mode 100644 index 0000000..ed8e22e --- /dev/null +++ b/m01-simulator.css @@ -0,0 +1,124 @@ +.sim-shell { + --sim-border: rgba(20, 20, 20, 0.14); + --sim-soft: rgba(20, 20, 20, 0.05); + max-width: 1120px; + margin: 0 auto; +} + +.sim-header, +.sim-briefing { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + margin-bottom: 28px; +} + +.sim-header h1, +.sim-briefing h1 { margin-bottom: 8px; } + +.sim-progress { + font: 700 0.85rem/1 monospace; + padding: 10px 12px; + border: 1px solid var(--sim-border); + border-radius: 999px; + white-space: nowrap; +} + +.sim-meters { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 24px; +} + +.sim-meter { + border: 1px solid var(--sim-border); + border-radius: 16px; + padding: 16px; + background: var(--paper, #fff); +} + +.sim-meter > span, +.sim-meter > small { display: block; } +.sim-meter > span { font-weight: 700; margin-bottom: 8px; } +.sim-meter strong { display: block; font-size: 1.8rem; margin-bottom: 10px; } +.sim-meter strong small { font-size: 0.75rem; font-weight: 500; } +.sim-meter-track { height: 7px; background: var(--sim-soft); border-radius: 999px; overflow: hidden; margin-bottom: 8px; } +.sim-meter-track span { display: block; height: 100%; background: currentColor; border-radius: inherit; transition: width 180ms ease; } + +.sim-situation, +.sim-decision-card, +.sim-tools, +.sim-consequence, +.sim-briefing-card, +.sim-review-grid > *, +.sim-reflection, +.sim-blocked { + border: 1px solid var(--sim-border); + border-radius: 18px; + padding: 22px; + background: var(--paper, #fff); + margin-bottom: 18px; +} + +.sim-situation > p:last-child, +.sim-consequence > p, +.sim-briefing-card p { max-width: 78ch; font-size: 1.04rem; line-height: 1.6; } + +.sim-tools-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.sim-tools-head p:last-child { max-width: 50ch; } +.sim-tool-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.sim-tool-drawer { margin-top: 16px; padding: 18px; border: 1px dashed var(--sim-border); border-radius: 14px; background: var(--sim-soft); } +.sim-tool-drawer ul { padding-left: 20px; } +.sim-tool-drawer li { margin: 8px 0; line-height: 1.45; } + +.sim-decision-card fieldset { border: 0; padding: 0; margin: 0; } +.sim-decision-card legend { font-size: 1.3rem; font-weight: 750; margin-bottom: 16px; } +.sim-options { display: grid; gap: 10px; } +.sim-option { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 10px; padding: 14px; border: 1px solid var(--sim-border); border-radius: 13px; cursor: pointer; } +.sim-option:has(input:checked) { outline: 2px solid currentColor; outline-offset: 1px; } +.sim-option input { margin-top: 3px; } +.sim-rationale { display: grid; gap: 8px; margin-top: 18px; } +.sim-rationale textarea { width: 100%; resize: vertical; min-height: 92px; } + +.sim-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; } +.sim-message { min-height: 1.4em; margin-top: 12px; font-weight: 650; } + +.sim-deltas { list-style: none; padding: 0; margin: 18px 0 0; display: grid; gap: 8px; } +.sim-deltas li { display: flex; justify-content: space-between; gap: 12px; padding-top: 8px; border-top: 1px solid var(--sim-border); } + +.sim-review-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr); gap: 16px; } +.sim-path { list-style: none; padding: 0; display: grid; gap: 12px; } +.sim-path li { display: grid; grid-template-columns: 34px 1fr; gap: 10px; } +.sim-path li > span { font: 700 0.8rem/1.6 monospace; } +.sim-path p { margin: 6px 0 0; } +.sim-reflection ul { margin-bottom: 0; } +.sim-blocked { max-width: 760px; } + +.sim-option:focus-within, +.sim-tool-buttons .button:focus-visible, +.sim-actions .button:focus-visible, +.sim-tool-drawer .button:focus-visible, +#sim-focus-target:focus-visible { + outline: 3px solid currentColor; + outline-offset: 3px; +} + +@media (max-width: 860px) { + .sim-meters { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sim-review-grid { grid-template-columns: 1fr; } + .sim-tools-head { display: block; } +} + +@media (max-width: 520px) { + .sim-meters { grid-template-columns: 1fr; } + .sim-header { display: block; } + .sim-progress { display: inline-block; margin-top: 8px; } + .sim-deltas li { display: grid; } +} + +@media (prefers-reduced-motion: reduce) { + .sim-meter-track span { transition: none; } + .sim-shell *, .sim-shell *::before, .sim-shell *::after { scroll-behavior: auto !important; } +} diff --git a/m01-validation-app.js b/m01-validation-app.js new file mode 100644 index 0000000..3c9a8ab --- /dev/null +++ b/m01-validation-app.js @@ -0,0 +1,456 @@ +(function () { + 'use strict'; + + const DATA = window.PM01 && window.PM01.m01Validation; + const DOMAIN = window.PM01Learning; + const validationStorageKey = 'pm01-validation-m01-v1'; + const legacyStorageKey = 'pm01-state-v1'; + const validationRoute = 'validation/m01'; + const m01LessonIds = ['project-system', 'system-diagnostic']; + + if (!DATA || !DOMAIN) return; + + const lessons = (window.PM01.modules || []).flatMap((module) => module.lessons || []); + + function lesson(id) { + return lessons.find((item) => item.id === id); + } + + function emptyAssessment() { + return { answers: {}, reasoning: '', submittedAt: null, score: null }; + } + + function emptyValidationState() { + return { + version: 1, + baseline: emptyAssessment(), + drills: {}, + postCase: emptyAssessment(), + field: { values: {}, submittedAt: null }, + reflection: { values: {}, delayedTransfer: '', submittedAt: null }, + }; + } + + function loadJson(key, fallback) { + try { + const parsed = JSON.parse(localStorage.getItem(key)); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch (_) { + return fallback; + } + } + + function loadValidationState() { + const stored = loadJson(validationStorageKey, {}); + const defaults = emptyValidationState(); + return { + ...defaults, + ...stored, + baseline: { ...defaults.baseline, ...(stored.baseline || {}), answers: { ...(stored.baseline && stored.baseline.answers || {}) } }, + drills: { ...(stored.drills || {}) }, + postCase: { ...defaults.postCase, ...(stored.postCase || {}), answers: { ...(stored.postCase && stored.postCase.answers || {}) } }, + field: { ...defaults.field, ...(stored.field || {}), values: { ...(stored.field && stored.field.values || {}) } }, + reflection: { ...defaults.reflection, ...(stored.reflection || {}), values: { ...(stored.reflection && stored.reflection.values || {}) } }, + }; + } + + let state = loadValidationState(); + let storageHealthy = testStorage(); + + function testStorage() { + const key = `${validationStorageKey}-probe`; + try { + localStorage.setItem(key, '1'); + localStorage.removeItem(key); + return true; + } catch (_) { + return false; + } + } + + function saveValidationState(message) { + try { + localStorage.setItem(validationStorageKey, JSON.stringify(state)); + storageHealthy = true; + if (message) setMessage(message); + return true; + } catch (_) { + storageHealthy = false; + setMessage('Не удалось сохранить данные в браузере. Не переходи к следующему шагу: ответы могут быть потеряны.', true); + return false; + } + } + + function legacyState() { + return loadJson(legacyStorageKey, { completed: [] }); + } + + function lessonEvidenceComplete(currentLesson, stored) { + if (!currentLesson || !currentLesson.learningLab) return true; + const lessonState = stored.lab && stored.lab[currentLesson.id] || {}; + const drillAnswers = lessonState.drillAnswers || {}; + const workbook = lessonState.workbook || {}; + const requiredDrills = currentLesson.learningLab.drills.filter((drill) => drill.required !== false); + const requiredFields = currentLesson.learningLab.workbookFields.filter((field) => field.required !== false); + return requiredDrills.every((drill) => Boolean(drillAnswers[drill.id])) + && requiredFields.every((field) => String(workbook[field.id] || '').trim().length > 0); + } + + function isStudied() { + const stored = legacyState(); + const completed = Array.isArray(stored.completed) ? stored.completed : []; + return m01LessonIds.every((id) => completed.includes(id) && lessonEvidenceComplete(lesson(id), stored)); + } + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function currentRoute() { + return location.hash.replace(/^#\/?/, '').replace(/\/$/, ''); + } + + function bindColdLabDrillLock() { + const route = currentRoute(); + const lessonId = route.startsWith('lesson/') ? route.slice('lesson/'.length) : null; + if (!lessonId || !m01LessonIds.includes(lessonId)) return; + + const currentLesson = lesson(lessonId); + const coldDrill = currentLesson && currentLesson.learningLab + ? currentLesson.learningLab.drills.find((drill) => drill.stage === 'cold') + : null; + if (!coldDrill) return; + + const inputs = [...document.querySelectorAll(`[data-lab-drill="${coldDrill.id}"]`)]; + if (!inputs.length) return; + + const lock = () => inputs.forEach((input) => { input.disabled = true; }); + const stored = legacyState(); + const answer = stored.lab && stored.lab[lessonId] + && stored.lab[lessonId].drillAnswers && stored.lab[lessonId].drillAnswers[coldDrill.id]; + + if (answer) { + lock(); + return; + } + + inputs.forEach((input) => input.addEventListener('change', lock)); + } + + function setMessage(text, error) { + const target = document.querySelector('#validation-message'); + if (!target) return; + target.textContent = text || ''; + target.dataset.error = error ? 'true' : 'false'; + } + + function assessmentComplete(assessment, draft) { + return assessment.questions.every((question) => draft.answers[question.id]) && draft.reasoning.trim().length >= 20; + } + + function assessmentBlock(assessment, key, heading, unlocked) { + const draft = state[key]; + const submitted = Boolean(draft.submittedAt); + const revealResults = submitted && (key !== 'baseline' || Boolean(state.postCase.submittedAt)); + if (!unlocked) { + return `
    +

    ${escapeHtml(heading)}

    ${escapeHtml(assessment.title)}

    Этот этап откроется после предыдущих шагов.

    +
    `; + } + + const questions = assessment.questions.map((question) => { + const selected = draft.answers[question.id]; + const options = question.options.map((item) => { + const checked = selected === item.id; + return `${revealResults && checked ? `

    ${escapeHtml(item.feedback)}

    ` : ''}`; + }).join(''); + return `
    + ${escapeHtml(question.prompt)} +
    ${options}
    +
    `; + }).join(''); + + const score = revealResults && draft.score ? `
    +
    ${draft.score.total}/${draft.score.max}итог по rubric
    +
    ${draft.score.answered}/${assessment.questions.length}зафиксировано ответов
    +
    зафиксированответ нельзя менять после submit
    +
    ` : ''; + + const submittedMessage = key === 'baseline' && !state.postCase.submittedAt + ? 'Baseline зафиксирован; результат скрыт до post-case, чтобы не загрязнять измерение.' + : 'Ответ зафиксирован. Feedback открыт после завершения измерения.'; + + return `
    +

    ${escapeHtml(heading)}

    ${escapeHtml(assessment.title)}

    ${escapeHtml(assessment.scenario)}

    +
    ${escapeHtml(assessment.reasoningPrompt)}
    + + + ${questions} + ${score} + ${submitted ? `

    ${submittedMessage}

    ` : `
    `} +
    `; + } + + function learningBlock() { + const studied = isStudied(); + return `
    +

    02 · Learning

    Пройди два урока M01

    Первый выбор и feedback уже встроены в Learning Lab каждого урока. Возвращайся сюда после завершения обоих уроков.

    + +

    Уроки: ${studied ? 'изучены ✓' : 'нужно завершить оба'}

    +
    `; + } + + function resultBlock() { + if (!state.baseline.score || !state.postCase.score) return ''; + const decision = DOMAIN.promotionDecision(state.baseline.score, state.postCase.score, DATA.promotionRule); + const dimensions = DATA.rubricDimensions.map((dimension) => { + const before = state.baseline.score.byDimension[dimension.id] || 0; + const after = state.postCase.score.byDimension[dimension.id] || 0; + const diff = after - before; + return `
    ${escapeHtml(dimension.label)}${before} → ${after}${diff > 0 ? ` (+${diff})` : diff < 0 ? ` (${diff})` : ''}
    `; + }).join(''); + const deltaLabel = decision.delta > 0 ? `+${decision.delta}` : String(decision.delta); + const explanation = decision.promoted + ? 'Есть прототипный сигнал улучшения: выполнены оба promotion gate. Это не означает mastery и требует проверки на реальном переносе.' + : `Promotion gate пока не пройден: требуется delta ≥ ${DATA.promotionRule.minDelta} и улучшение минимум по ${DATA.promotionRule.minDimensionsImproved} измерениям.`; + return `
    +

    Learning signal

    Что изменилось в reasoning

    +
    +
    ${state.baseline.score.total}/15baseline
    +
    ${state.postCase.score.total}/15post-case
    +
    ${deltaLabel}delta
    +
    +

    ${escapeHtml(explanation)}

    +
    ${dimensions}
    +
    `; + } + + function fieldBlock(unlocked) { + if (!unlocked) return `

    04 · Transfer

    Перенос на реальный проект

    Откроется после post-case.

    `; + const submitted = Boolean(state.field.submittedAt); + const fields = DATA.fieldApplication.fields.map((field) => `
    + + +
    `).join(''); + return `
    +

    04 · Transfer

    ${escapeHtml(DATA.fieldApplication.title)}

    ${escapeHtml(DATA.fieldApplication.instructions)}

    +
    ${fields}
    + ${submitted ? '

    Field application зафиксирован ✓

    ' : '
    '} +
    `; + } + + function reflectionBlock(unlocked) { + if (!unlocked) return `

    05 · Reflection

    Обновление mental model

    Откроется после field application.

    `; + const submitted = Boolean(state.reflection.submittedAt); + const prompts = DATA.reflection.prompts.map((prompt) => `
    + + +
    `).join(''); + return `
    +

    05 · Reflection

    ${escapeHtml(DATA.reflection.title)}

    Не пересказывай урок. Зафиксируй, что именно изменилось в диагнозе, действии или требованиях к доказательствам.

    +
    ${prompts} +
    + + +
    +
    + ${submitted ? '

    Reflection зафиксирован ✓

    ' : '
    '} +
    `; + } + + function evidenceStateLabel() { + const studied = isStudied(); + const fieldApplied = Boolean(state.field.submittedAt); + const learningState = DOMAIN.deriveLearningState({ studied, fieldApplied, transferEvidence: false }); + const labels = { unseen: 'не начато', studied: 'изучено', applied: 'применено', mastered: 'mastered' }; + return labels[learningState] || learningState; + } + + function validationView() { + const baselineDone = Boolean(state.baseline.submittedAt); + const postUnlocked = baselineDone && isStudied(); + const postDone = Boolean(state.postCase.submittedAt); + const fieldDone = Boolean(state.field.submittedAt); + const storageWarning = storageHealthy ? '' : '

    Браузер сейчас не дает сохранить localStorage. Не продолжай эксперимент до восстановления хранения: переход к урокам может привести к потере ответов.

    '; + + return `
    +
    +

    Phase 1 · Reference prototype

    +

    ${escapeHtml(DATA.title)}

    +

    ${escapeHtml(DATA.promise)}

    +

    Это эксперимент качества обучения, а не экзамен. Система измеряет изменение reasoning; статус mastered автоматически не присваивается.

    + ${storageWarning} + Evidence state: ${escapeHtml(evidenceStateLabel())} +
    + + ${assessmentBlock(DATA.baseline, 'baseline', '01 · Baseline', true)} + ${baselineDone ? learningBlock() : '

    02 · Learning

    Сначала зафиксируй baseline

    Материал модуля намеренно не показывается в этом маршруте до baseline, чтобы не загрязнять исходное измерение.

    '} + ${assessmentBlock(DATA.postCase, 'postCase', '03 · Integrative case', postUnlocked)} + ${postDone ? resultBlock() : ''} + ${fieldBlock(postDone)} + ${reflectionBlock(fieldDone)} + +
    + ← К программе + +
    +
    +
    `; + } + + function fieldComplete() { + return DATA.fieldApplication.fields.every((field) => String(state.field.values[field.id] || '').trim().length >= 8); + } + + function reflectionComplete() { + return DATA.reflection.prompts.every((prompt) => String(state.reflection.values[prompt.id] || '').trim().length >= 12); + } + + function rerenderValidation(message) { + renderValidationRoute(); + if (message) setMessage(message); + } + + function bindAssessmentEvents() { + document.querySelectorAll('[data-assessment]').forEach((input) => input.addEventListener('change', () => { + const key = input.dataset.assessment; + if (state[key].submittedAt) return; + state[key].answers[input.dataset.question] = input.value; + saveValidationState('Черновик ответа сохранен.'); + })); + + document.querySelectorAll('[data-reasoning]').forEach((textarea) => textarea.addEventListener('change', () => { + const key = textarea.dataset.reasoning; + if (state[key].submittedAt) return; + state[key].reasoning = textarea.value; + saveValidationState('Черновик reasoning сохранен.'); + })); + + document.querySelectorAll('[data-submit-assessment]').forEach((button) => button.addEventListener('click', () => { + const key = button.dataset.submitAssessment; + const assessment = key === 'baseline' ? DATA.baseline : DATA.postCase; + const reasoning = document.querySelector(`[data-reasoning="${key}"]`); + if (reasoning) state[key].reasoning = reasoning.value; + if (!assessmentComplete(assessment, state[key])) { + saveValidationState(); + setMessage('Ответь на все пять вопросов и запиши диагноз минимум в 20 символах.', true); + return; + } + state[key].score = DOMAIN.scoreAssessment(assessment.questions, state[key].answers); + state[key].submittedAt = new Date().toISOString(); + if (saveValidationState()) rerenderValidation(`${key === 'baseline' ? 'Baseline' : 'Post-case'} зафиксирован.`); + })); + } + + function bindFieldEvents() { + document.querySelectorAll('[data-field]').forEach((textarea) => textarea.addEventListener('change', () => { + if (state.field.submittedAt) return; + state.field.values[textarea.dataset.field] = textarea.value; + saveValidationState('Черновик field application сохранен.'); + })); + document.querySelector('#save-field')?.addEventListener('click', () => { + document.querySelectorAll('[data-field]').forEach((textarea) => { state.field.values[textarea.dataset.field] = textarea.value; }); + saveValidationState('Черновик field application сохранен.'); + }); + document.querySelector('#submit-field')?.addEventListener('click', () => { + document.querySelectorAll('[data-field]').forEach((textarea) => { state.field.values[textarea.dataset.field] = textarea.value; }); + if (!fieldComplete()) { + saveValidationState(); + setMessage('Заполни все поля field application содержательно (минимум 8 символов в каждом).', true); + return; + } + state.field.submittedAt = new Date().toISOString(); + if (saveValidationState()) rerenderValidation('Field application зафиксирован.'); + }); + } + + function bindReflectionEvents() { + document.querySelectorAll('[data-reflection]').forEach((textarea) => textarea.addEventListener('change', () => { + if (state.reflection.submittedAt) return; + state.reflection.values[textarea.dataset.reflection] = textarea.value; + saveValidationState('Черновик reflection сохранен.'); + })); + document.querySelector('[data-delayed-transfer]')?.addEventListener('change', (event) => { + if (state.reflection.submittedAt) return; + state.reflection.delayedTransfer = event.currentTarget.value; + saveValidationState('Черновик delayed transfer сохранен.'); + }); + const collect = () => { + document.querySelectorAll('[data-reflection]').forEach((textarea) => { state.reflection.values[textarea.dataset.reflection] = textarea.value; }); + const delayed = document.querySelector('[data-delayed-transfer]'); + if (delayed) state.reflection.delayedTransfer = delayed.value; + }; + document.querySelector('#save-reflection')?.addEventListener('click', () => { + collect(); + saveValidationState('Черновик reflection сохранен.'); + }); + document.querySelector('#submit-reflection')?.addEventListener('click', () => { + collect(); + if (!reflectionComplete()) { + saveValidationState(); + setMessage('Ответь на все обязательные reflection prompts содержательно (минимум 12 символов).', true); + return; + } + state.reflection.submittedAt = new Date().toISOString(); + if (saveValidationState()) rerenderValidation('Reflection зафиксирован. M01 validation готов к review.'); + }); + } + + function bindReset() { + document.querySelector('#reset-validation')?.addEventListener('click', () => { + if (!window.confirm('Удалить только данные M01 validation в этом браузере? Прогресс основного курса не изменится.')) return; + try { + localStorage.removeItem(validationStorageKey); + state = emptyValidationState(); + storageHealthy = testStorage(); + rerenderValidation('M01 validation data сброшены.'); + } catch (_) { + setMessage('Не удалось сбросить данные браузера.', true); + } + }); + } + + function renderValidationRoute() { + const main = document.querySelector('#main'); + if (!main) return; + main.innerHTML = validationView(); + document.querySelectorAll('.main-nav a').forEach((link) => link.classList.toggle('active', link.dataset.route === 'course')); + bindAssessmentEvents(); + bindFieldEvents(); + bindReflectionEvents(); + bindReset(); + main.focus({ preventScroll: true }); + } + + function decorateCourse() { + if (document.querySelector('[data-validation-cta]')) return; + const moduleList = document.querySelector('.module-list'); + if (!moduleList) return; + moduleList.insertAdjacentHTML('beforebegin', `
    +

    Phase 1 · M01

    +

    Проверить, изменилось ли системное мышление

    +

    Отдельный маршрут фиксирует baseline до обучения, затем post-case, перенос на реальный проект и reflection. Результат — learning signal, не сертификат и не автоматический mastery.

    + Начать M01 validation → +
    `); + } + + function renderExtension() { + if (currentRoute() === validationRoute) renderValidationRoute(); + else if (currentRoute() === 'course') decorateCourse(); + else bindColdLabDrillLock(); + } + + window.addEventListener('hashchange', () => queueMicrotask(renderExtension)); + renderExtension(); +})(); \ No newline at end of file diff --git a/m01-validation-data.js b/m01-validation-data.js new file mode 100644 index 0000000..2fcf172 --- /dev/null +++ b/m01-validation-data.js @@ -0,0 +1,182 @@ +(function () { + 'use strict'; + + const PM01 = window.PM01; + if (!PM01) throw new Error('PM01 course data must be loaded before M01 validation data'); + + const rubricDimensions = [ + { id: 'mechanism', label: 'Механизм', description: 'Отделяет симптом от воспроизводящей причины в системе.' }, + { id: 'evidence', label: 'Доказательства', description: 'Опирается на наблюдаемые факты и явно отмечает неизвестное.' }, + { id: 'tradeoffs', label: 'Компромиссы', description: 'Видит цену вмешательства и альтернативные объяснения.' }, + { id: 'intervention', label: 'Вмешательство', description: 'Выбирает действие, направленное на механизм, а не на внешний симптом.' }, + { id: 'changeCondition', label: 'Условие изменения', description: 'Заранее задает сигнал, по которому решение будет подтверждено или пересмотрено.' }, + ]; + + function option(id, score, label, feedback) { + return { id, score, label, feedback }; + } + + PM01.m01Validation = { + version: 1, + moduleId: 'm01', + title: 'M01 · Проверка системного мышления', + promise: 'Сравнить качество диагноза проекта до и после модуля, а затем перенести модель на реальную работу.', + rubricDimensions, + promotionRule: { minDelta: 3, minDimensionsImproved: 2 }, + + baseline: { + id: 'm01-baseline-release', + title: 'Baseline · Релиз снова сдвинулся', + scenario: 'Команда из восьми человек третий раз переносит релиз. Разработка говорит, что требования меняются слишком поздно. Аналитик говорит, что бизнес долго отвечает. Бизнес считает, что разработка завышает оценки. QA получает крупные изменения за два дня до релиза. На статусах все задачи почти всегда зеленые до последней недели.', + reasoningPrompt: 'Кратко запиши свой диагноз до выбора вариантов: где проблема, почему она повторяется и что ты бы сделал первым?', + questions: [ + { + id: 'baseline-mechanism', dimension: 'mechanism', prompt: 'Какой диагноз лучше всего объясняет повторяемость проблемы?', + options: [ + option('people', 0, 'Команда недостаточно дисциплинирована', 'Это объясняет проблему качествами людей, но не показывает воспроизводящий механизм системы.'), + option('estimates', 1, 'Оценки разработки систематически неточны', 'Неточность оценок может быть симптомом, но не объясняет поздние изменения и очереди решений.'), + option('handoff', 2, 'Поздние требования создают перегрузку перед QA', 'Это находит важный механизм, но пока оставляет без ответа источник поздних требований.'), + option('decision-flow', 3, 'Решения и уточнения долго ждут upstream, затем пакетно попадают в исполнение', 'Диагноз связывает задержку решений с накоплением работы и поздней перегрузкой downstream.'), + ], + }, + { + id: 'baseline-evidence', dimension: 'evidence', prompt: 'Какое доказательство стоит получить первым?', + options: [ + option('opinions', 0, 'Собрать мнения всех участников о виноватой стороне', 'Мнения полезны как гипотезы, но сами по себе не показывают движение работы и решений.'), + option('velocity', 1, 'Сравнить velocity последних спринтов', 'Velocity показывает объем завершенной работы, но слабо локализует ожидание и поздние изменения.'), + option('changes', 2, 'Посчитать изменения требований в последнюю неделю', 'Это полезный факт, но без времени ожидания решений не показывает всю причинную цепь.'), + option('timeline', 3, 'Восстановить timeline ключевых решений, изменений и передачи в QA', 'Timeline позволяет увидеть очереди, задержки и связь upstream-событий с downstream-перегрузкой.'), + ], + }, + { + id: 'baseline-tradeoffs', dimension: 'tradeoffs', prompt: 'Какой компромисс важно признать до вмешательства?', + options: [ + option('none', 0, 'Если процесс правильный, компромиссов быть не должно', 'Любое системное вмешательство меняет скорость, гибкость, загрузку или объем доступной информации.'), + option('overtime', 1, 'Нужно решить, сколько переработок допустимо', 'Это рассматривает компенсацию симптома, а не основной выбор дизайна потока.'), + option('freeze', 2, 'Ранний freeze снизит изменения, но может зафиксировать неверные решения', 'Компромисс реальный, однако freeze — только один из возможных механизмов управления неопределенностью.'), + option('batch-vs-learning', 3, 'Меньшие партии и ранние решения снижают поздний риск, но требуют чаще вовлекать бизнес', 'Ответ явно связывает улучшение потока с ценой более частых решений и обратной связи.'), + ], + }, + { + id: 'baseline-intervention', dimension: 'intervention', prompt: 'Что разумнее попробовать первым?', + options: [ + option('pressure', 0, 'Потребовать от команды точнее соблюдать исходный план', 'Давление на исполнение не устраняет ожидание решений и может лишь скрыть риск дольше.'), + option('more-qa', 1, 'Добавить QA на последнюю неделю перед релизом', 'Это увеличит локальную мощность, но сохранит пакетный поток поздних изменений.'), + option('freeze', 2, 'Запретить изменения за неделю до релиза', 'Это может стабилизировать конец цикла, но не уменьшает upstream-очередь решений.'), + option('decision-sla', 3, 'Сократить время ключевых решений и дробить передачу изменений небольшими партиями', 'Вмешательство направлено на найденный механизм: ожидание upstream и позднее пакетное поступление работы.'), + ], + }, + { + id: 'baseline-change', dimension: 'changeCondition', prompt: 'Какой ранний сигнал покажет, что вмешательство работает?', + options: [ + option('on-time', 0, 'Следующий релиз выйдет вовремя', 'Это слишком поздний итоговый результат и не помогает вовремя скорректировать вмешательство.'), + option('busy', 1, 'У всех будет стабильная загрузка', 'Высокая загрузка не равна хорошему потоку и может даже увеличивать очереди.'), + option('bugs', 2, 'Снизится число дефектов перед релизом', 'Это полезный downstream-сигнал, но он появляется позже причины и смешивает несколько механизмов.'), + option('lead-time', 3, 'Снизится время от вопроса/изменения до решения и передачи следующему этапу', 'Это ранний показатель именно того механизма, который должно изменить вмешательство.'), + ], + }, + ], + }, + + decisionDrills: [ + { + id: 'm01-drill-system', lessonId: 'project-system', title: 'Drill · Красная задача', + situation: 'Одна задача просрочена на пять дней. Разработчик три дня ждал API внешней команды, но в плане это ожидание не отображалось.', + prompt: 'Какое действие сильнее всего соответствует системному взгляду?', + options: [ + option('escalate-dev', 0, 'Эскалировать просрочку разработчика', 'Это реагирует на место проявления симптома, а не на источник ожидания.'), + option('extend', 1, 'Увеличить оценку похожих задач', 'Большая оценка может скрыть вариативность, но не делает зависимость управляемой.'), + option('track-dependency', 3, 'Сделать внешнюю зависимость явным узлом потока с владельцем и ранним сигналом', 'Так вмешательство меняет систему, которая воспроизводит ожидание, а не только текущую дату.'), + ], + }, + { + id: 'm01-drill-diagnostic', lessonId: 'system-diagnostic', title: 'Drill · Дефект в QA', + situation: 'QA три раза возвращает одну и ту же функцию из-за разных трактовок бизнес-правила. Требование было согласовано в переписке несколькими людьми.', + prompt: 'Где остановить причинную цепь для первого системного вмешательства?', + options: [ + option('qa', 0, 'На QA: усилить чек-лист тестирования', 'QA обнаруживает расхождение, но чек-лист не устраняет неоднозначное решение upstream.'), + option('developer', 1, 'На разработчике: обязать задавать больше вопросов', 'Это может помочь локально, но снова делает устойчивость зависимой от поведения конкретного человека.'), + option('decision-interface', 3, 'На интерфейсе решения: одно правило, владелец трактовки и проверяемый пример до разработки', 'Условие системы становится явным и снижает вероятность повторения с другим исполнителем.'), + ], + }, + ], + + postCase: { + id: 'm01-post-integration', + title: 'Post-case · Интеграция к запуску партнера', + scenario: 'Команда готовит интеграцию к публичному запуску партнера. Техническая часть почти завершена, но юридические ограничения и формат данных уточняются через двух менеджеров. Разработчики держат несколько вариантов реализации открытыми. За неделю до запуска партнер присылает окончательный формат, после чего приходится менять обработку данных, документацию и тесты одновременно.', + reasoningPrompt: 'Запиши диагноз заново без подсказок из baseline: какой механизм создает риск и какое вмешательство ты выберешь?', + questions: [ + { + id: 'post-mechanism', dimension: 'mechanism', prompt: 'Какой механизм наиболее вероятно создает повторяемый риск?', + options: [ + option('coding', 0, 'Разработка недостаточно быстро переписывает интеграцию', 'Скорость переписывания относится к реакции на позднюю информацию, а не к источнику риска.'), + option('partner', 1, 'Партнер просто ненадежен, это нельзя управлять', 'Внешняя неопределенность реальна, но систему можно спроектировать так, чтобы раньше получать решения и ограничивать последствия.'), + option('parallel', 2, 'Слишком много вариантов реализации поддерживаются параллельно', 'Это важная стоимость неопределенности, но нужно объяснить, почему развилка остается открытой так долго.'), + option('decision-interface', 3, 'Критичные внешние решения проходят длинный интерфейс и приходят большой партией перед необратимым сроком', 'Ответ связывает очередь решений, размер партии и позднее распространение изменений по системе.'), + ], + }, + { + id: 'post-evidence', dimension: 'evidence', prompt: 'Какой набор фактов лучше проверит этот диагноз?', + options: [ + option('hours', 0, 'Количество часов разработки по интеграции', 'Трудозатраты не показывают, сколько времени работа ожидала внешней информации или решения.'), + option('messages', 1, 'Количество сообщений с партнером', 'Объем коммуникации не равен скорости получения решения и может маскировать повторные уточнения.'), + option('rework', 2, 'Объем переделок после получения финального формата', 'Переделки показывают цену задержки, но без timeline не локализуют механизм.'), + option('decision-timeline', 3, 'Время каждого критичного вопроса: запрос → владелец → ответ → изменение downstream', 'Так можно проверить и очередь решений, и распространение последствий по системе.'), + ], + }, + { + id: 'post-tradeoffs', dimension: 'tradeoffs', prompt: 'Какой trade-off должен быть явным?', + options: [ + option('quality-speed', 0, 'Качество всегда приходится жертвовать ради скорости', 'Это слишком общий тезис и не связан с механизмом конкретной системы.'), + option('people', 1, 'Больше встреч означает меньше времени на разработку', 'Это реальная стоимость, но не формулирует управленческий выбор вокруг неопределенности.'), + option('flexibility', 2, 'Раннее решение уменьшит переделки, но может ограничить гибкость', 'Компромисс верный, но не учитывает возможность поэтапно закрывать только дорогие развилки.'), + option('optionality', 3, 'Нужно платить за опциональность только там, где стоимость позднего решения ниже стоимости преждевременной фиксации', 'Ответ связывает время решения, цену неопределенности и необратимость вместо универсального freeze.'), + ], + }, + { + id: 'post-intervention', dimension: 'intervention', prompt: 'Какое первое вмешательство наиболее системное?', + options: [ + option('heroics', 0, 'Заранее запланировать усиление команды перед запуском', 'Резерв мощности помогает пережить симптом, но не улучшает поток решений.'), + option('deadline', 1, 'Поставить партнеру более жесткий дедлайн ответа', 'Дедлайн без владельца, формата решения и последствий часто не меняет реальную очередь.'), + option('mock', 2, 'Сделать больше технических моков всех возможных форматов', 'Это снижает часть технического риска, но может дорого поддерживать ненужную опциональность.'), + option('decision-contract', 3, 'Выделить критичные развилки, владельцев и даты решений; закрывать их до того, как downstream-цена станет высокой', 'Вмешательство сокращает время открытых дорогих развилок и делает внешнюю зависимость управляемой.'), + ], + }, + { + id: 'post-change', dimension: 'changeCondition', prompt: 'Какой change condition лучше использовать?', + options: [ + option('launch', 0, 'Запуск состоится без переноса', 'Это поздний бинарный итог и слабый датчик качества потока решений.'), + option('meetings', 1, 'С партнерами станет меньше встреч', 'Количество встреч может измениться в любую сторону и не отражает скорость закрытия критичных развилок.'), + option('rework', 2, 'Снизится объем переделок перед запуском', 'Это хороший lagging signal, но он не позволяет рано увидеть возвращение очереди решений.'), + option('open-decisions', 3, 'Возраст и число открытых критичных решений снижаются до согласованных порогов', 'Это ранний и непосредственно связанный с механизмом сигнал, который позволяет пересмотреть способ взаимодействия заранее.'), + ], + }, + ], + }, + + fieldApplication: { + title: 'Перенос на реальный проект', + instructions: 'Выбери текущий или недавний проект. Не описывай идеальный процесс — зафиксируй факты, одно вмешательство и наблюдаемое доказательство.', + fields: [ + { id: 'project', label: 'Проект и контекст', prompt: 'Что за проект и какой наблюдаемый outcome важен?' }, + { id: 'symptom', label: 'Симптом', prompt: 'Где проблема становится видимой?' }, + { id: 'mechanism', label: 'Механизм', prompt: 'Какой повторяемый механизм или условие системы создает симптом?' }, + { id: 'intervention', label: 'Вмешательство', prompt: 'Какое минимальное изменение системы ты попробуешь?' }, + { id: 'signal', label: 'Ранний сигнал', prompt: 'Какой показатель должен измениться раньше итогового результата?' }, + { id: 'evidence', label: 'Наблюдаемое доказательство', prompt: 'Что фактически произошло после вмешательства? Если еще не проверено — так и укажи.' }, + { id: 'nextDecision', label: 'Следующее решение', prompt: 'Что ты продолжишь, изменишь или остановишь на основании доказательства?' }, + ], + }, + + reflection: { + title: 'Обновление модели', + prompts: [ + { id: 'changedDiagnosis', label: 'Что изменилось в диагнозе по сравнению с baseline?' }, + { id: 'changedAction', label: 'Как изменилось выбранное первое действие и почему?' }, + { id: 'newEvidence', label: 'Какие данные теперь нужны тебе раньше, чем раньше?' }, + { id: 'remainingUncertainty', label: 'В чем ты все еще не уверен и как это проверишь?' }, + ], + }, + }; +})(); diff --git a/m01-validation-simulator-gate.js b/m01-validation-simulator-gate.js new file mode 100644 index 0000000..57d841e --- /dev/null +++ b/m01-validation-simulator-gate.js @@ -0,0 +1,100 @@ +(function () { + 'use strict'; + + const simulatorStorageKey = 'pm01-sim-m01-v1'; + const legacyStorageKey = 'pm01-state-v1'; + const treatmentId = 'm01-mission-partner-launch-v1'; + const missionVersion = 1; + const m01LessonIds = ['project-system', 'system-diagnostic']; + const expectedDecisionIds = ['d1', 'd2', 'd3', 'd4']; + + function currentRoute() { + return location.hash.replace(/^#\/?/, '').replace(/\/$/, ''); + } + + function parse(value, fallback) { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch (_) { + return fallback; + } + } + + function decisionEvidenceValid(decisions) { + if (!Array.isArray(decisions) || decisions.length !== expectedDecisionIds.length) return false; + if (!expectedDecisionIds.every((id, index) => decisions[index] && decisions[index].decisionId === id)) return false; + const rationaleIds = new Set(['d1', 'd4']); + return decisions.every((decision) => { + if (!decision || !decision.optionId) return false; + if (!rationaleIds.has(decision.decisionId)) return true; + return String(decision.rationale || '').trim().length >= 8; + }); + } + + function simulatorComplete(rawGet) { + const envelope = parse(rawGet(simulatorStorageKey), null); + if (!envelope) return false; + if (envelope.treatmentId !== treatmentId || envelope.missionVersion !== missionVersion) return false; + if (!envelope.run || !decisionEvidenceValid(envelope.run.decisions)) return false; + return Boolean(envelope.completedAt && envelope.reviewReachedAt && envelope.screen === 'review'); + } + + function sanitizedLegacyState(rawGet) { + const legacy = parse(rawGet(legacyStorageKey), {}); + const completed = Array.isArray(legacy.completed) + ? legacy.completed.filter((id) => !m01LessonIds.includes(id)) + : []; + const lab = { ...(legacy.lab || {}) }; + m01LessonIds.forEach((id) => { delete lab[id]; }); + return { ...legacy, completed, lab }; + } + + function syntheticEvidenceFor(id) { + const lessons = (window.PM01 && window.PM01.modules || []).flatMap((module) => module.lessons || []); + const lesson = lessons.find((item) => item.id === id); + const learningLab = lesson && lesson.learningLab; + if (!learningLab) return { drillAnswers: {}, workbook: {} }; + const drillAnswers = {}; + const workbook = {}; + learningLab.drills.filter((item) => item.required !== false).forEach((drill) => { + drillAnswers[drill.id] = drill.options && drill.options[0] ? drill.options[0].id : 'simulator-evidence'; + }); + learningLab.workbookFields.filter((item) => item.required !== false).forEach((field) => { + workbook[field.id] = 'simulator-evidence'; + }); + return { drillAnswers, workbook }; + } + + function installGate() { + const storage = localStorage; + const original = storage.getItem.bind(storage); + const gatedGetItem = function (key) { + if (key !== legacyStorageKey || currentRoute() !== 'validation/m01') return original(key); + const virtualState = sanitizedLegacyState(original); + if (!simulatorComplete(original)) return JSON.stringify(virtualState); + const lab = { ...(virtualState.lab || {}) }; + m01LessonIds.forEach((id) => { lab[id] = syntheticEvidenceFor(id); }); + return JSON.stringify({ + ...virtualState, + completed: [...new Set([...(virtualState.completed || []), ...m01LessonIds])], + lab, + }); + }; + + try { + Object.defineProperty(storage, 'getItem', { configurable: true, value: gatedGetItem }); + } catch (_) { + try { storage.getItem = gatedGetItem; } catch (_) { /* no-op: tests surface failure */ } + } + + window.PM01SimulatorGate = { + storageKey: simulatorStorageKey, + treatmentId, + missionVersion, + isComplete: () => simulatorComplete(original), + }; + } + + installGate(); +})(); diff --git a/m01-validation.css b/m01-validation.css new file mode 100644 index 0000000..37df02a --- /dev/null +++ b/m01-validation.css @@ -0,0 +1,49 @@ +.validation-shell { max-width: 980px; margin: 0 auto; } +.validation-hero { margin-bottom: 38px; padding-bottom: 30px; border-bottom: 1px solid var(--line); } +.validation-hero h1 { max-width: 860px; } +.validation-note { max-width: 760px; padding: 14px 16px; border-left: 3px solid var(--accent); background: rgba(255,255,255,.035); color: #c4d0d7; line-height: 1.6; } +.validation-step { margin: 38px 0; padding: 26px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--ink-2); } +.validation-step.locked { opacity: .65; } +.validation-step > header { margin-bottom: 20px; } +.validation-step > header p { color: var(--muted); line-height: 1.6; } +.validation-scenario { padding: 20px; border-radius: 10px; background: #06101a; color: #d0dae0; line-height: 1.65; } +.validation-question { margin: 22px 0; padding: 0; border: 0; } +.validation-question legend { margin-bottom: 10px; font-weight: 800; line-height: 1.45; } +.validation-options { display: grid; gap: 8px; } +.validation-option { display: flex; gap: 10px; align-items: flex-start; padding: 12px 13px; border: 1px solid var(--line); border-radius: 8px; color: #c4cfd6; cursor: pointer; } +.validation-option:has(input:checked) { border-color: var(--accent); background: rgba(255,177,27,.08); color: white; } +.validation-option input { margin-top: 3px; accent-color: var(--accent); } +.validation-feedback { margin: 8px 0 0 27px; color: #91a2af; font-size: 13px; line-height: 1.55; } +.validation-reasoning, .validation-evidence textarea { width: 100%; min-height: 120px; margin-top: 8px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: #06101a; color: white; resize: vertical; line-height: 1.55; } +.validation-reasoning:focus, .validation-evidence textarea:focus { outline: 3px solid rgba(255,177,27,.25); border-color: var(--accent); } +.validation-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; } +.validation-score { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 20px 0; } +.validation-score > div { padding: 16px; border: 1px solid var(--line); border-radius: 9px; background: rgba(255,255,255,.025); } +.validation-score strong { display: block; font-size: 26px; } +.validation-score span { color: var(--muted); font-size: 12px; } +.validation-dimensions { display: grid; gap: 8px; margin-top: 14px; } +.validation-dimension { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: 13px; } +.validation-dimension span:last-child { color: var(--muted); } +.validation-drills { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.validation-drill { padding: 20px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,.025); } +.validation-drill p { color: #bcc8cf; line-height: 1.55; } +.validation-lesson-links { display: flex; flex-wrap: wrap; gap: 9px; margin: 15px 0 22px; } +.validation-evidence { display: grid; gap: 18px; } +.validation-evidence label { display: block; font-weight: 700; } +.validation-evidence small { display: block; margin-top: 5px; color: var(--muted); font-weight: 400; line-height: 1.45; } +.validation-state { display: inline-flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 99px; color: #cbd5db; font: 12px "IBM Plex Mono", monospace; } +.validation-message { min-height: 24px; margin-top: 14px; color: var(--muted); line-height: 1.5; } +.validation-cta { margin: 28px 0; padding: 24px; border: 1px solid rgba(255,177,27,.35); border-radius: var(--radius); background: rgba(255,177,27,.055); } +.validation-cta h2 { margin-bottom: 9px; font-size: 25px; } +.validation-cta p { max-width: 760px; color: #b9c6ce; line-height: 1.55; } +.validation-cta .button { margin-top: 8px; } +.validation-result { margin-top: 24px; padding: 20px; border-radius: 10px; background: var(--paper); color: var(--ink); } +.validation-result p { color: #59666e; line-height: 1.55; } +.validation-result .validation-score > div { border-color: var(--paper-line); background: rgba(7,18,29,.035); } +.validation-result .validation-dimension { border-color: var(--paper-line); } +.validation-result .validation-dimension span:last-child { color: #65717a; } + +@media (max-width: 760px) { + .validation-step { padding: 20px; } + .validation-score, .validation-drills { grid-template-columns: 1fr; } +} diff --git a/tests/learning-domain.test.js b/tests/learning-domain.test.js new file mode 100644 index 0000000..1b06feb --- /dev/null +++ b/tests/learning-domain.test.js @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const domainPath = path.join(__dirname, '..', 'learning-domain.js'); +const domainExists = fs.existsSync(domainPath); + +let domain = null; +if (domainExists) domain = require(domainPath); + +test('learning domain module exists', () => { + assert.equal(domainExists, true, 'learning-domain.js must exist'); +}); + +test('scoreAssessment totals selected rubric scores by dimension', { skip: !domainExists }, () => { + const questions = [ + { id: 'q1', dimension: 'mechanism', options: [{ id: 'weak', score: 0 }, { id: 'strong', score: 3 }] }, + { id: 'q2', dimension: 'evidence', options: [{ id: 'partial', score: 2 }] }, + { id: 'q3', dimension: 'mechanism', options: [{ id: 'basic', score: 1 }] }, + ]; + const result = domain.scoreAssessment(questions, { q1: 'strong', q2: 'partial', q3: 'basic' }); + + assert.deepEqual(result, { + total: 6, + max: 9, + byDimension: { mechanism: 4, evidence: 2 }, + answered: 3, + }); +}); + +test('scoreAssessment gives no invented credit for unanswered questions', { skip: !domainExists }, () => { + const questions = [ + { id: 'q1', dimension: 'mechanism', options: [{ id: 'strong', score: 3 }] }, + { id: 'q2', dimension: 'evidence', options: [{ id: 'strong', score: 3 }] }, + ]; + const result = domain.scoreAssessment(questions, { q1: 'strong' }); + + assert.equal(result.total, 3); + assert.equal(result.max, 6); + assert.equal(result.answered, 1); + assert.deepEqual(result.byDimension, { mechanism: 3, evidence: 0 }); +}); + +test('promotionDecision promotes only when delta and dimension gates both pass', { skip: !domainExists }, () => { + const baseline = { total: 5, byDimension: { mechanism: 1, evidence: 1, tradeoffs: 1, intervention: 1, changeCondition: 1 } }; + const post = { total: 9, byDimension: { mechanism: 3, evidence: 2, tradeoffs: 1, intervention: 2, changeCondition: 1 } }; + const result = domain.promotionDecision(baseline, post); + + assert.equal(result.promoted, true); + assert.equal(result.delta, 4); + assert.deepEqual(result.improvedDimensions.sort(), ['evidence', 'intervention', 'mechanism']); +}); + +test('promotionDecision rejects a large delta concentrated in one dimension', { skip: !domainExists }, () => { + const baseline = { total: 2, byDimension: { mechanism: 0, evidence: 2 } }; + const post = { total: 5, byDimension: { mechanism: 3, evidence: 2 } }; + const result = domain.promotionDecision(baseline, post); + + assert.equal(result.delta, 3); + assert.equal(result.promoted, false); + assert.deepEqual(result.improvedDimensions, ['mechanism']); +}); + +test('deriveLearningState never assigns mastered from study or immediate application alone', { skip: !domainExists }, () => { + assert.equal(domain.deriveLearningState({}), 'unseen'); + assert.equal(domain.deriveLearningState({ studied: true }), 'studied'); + assert.equal(domain.deriveLearningState({ studied: true, fieldApplied: true }), 'applied'); + assert.equal(domain.deriveLearningState({ studied: true, fieldApplied: true, transferEvidence: true }), 'mastered'); +}); diff --git a/tests/learning-flow.test.js b/tests/learning-flow.test.js new file mode 100644 index 0000000..2d363b6 --- /dev/null +++ b/tests/learning-flow.test.js @@ -0,0 +1,106 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const appCode = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); + +function storageFrom(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key) { return values.has(key) ? values.get(key) : null; }, + setItem(key, value) { values.set(key, String(value)); }, + removeItem(key) { values.delete(key); }, + }; +} + +function classListStub() { + return { add() {}, remove() {}, toggle() { return false; } }; +} + +function runBaseApp(route, legacyState = {}) { + const main = { innerHTML: '', focus() {} }; + const sidebarProgress = { innerHTML: '' }; + const mobileNav = { classList: classListStub() }; + const menuButton = { classList: classListStub(), addEventListener() {}, setAttribute() {} }; + const notes = { value: '' }; + const criterion = { checked: false, dataset: { criterion: '0' }, addEventListener() {} }; + const completeButton = { + textContent: '', + disabled: false, + classList: classListStub(), + listeners: {}, + addEventListener(type, callback) { this.listeners[type] = callback; }, + }; + const saveNotes = { addEventListener() {} }; + const toast = { textContent: '', classList: classListStub() }; + + const document = { + querySelector(selector) { + if (selector === '#main') return main; + if (selector === '#sidebar-progress') return sidebarProgress; + if (selector === '#mobile-nav') return mobileNav; + if (selector === '#menu-button') return menuButton; + if (selector === '#lesson-notes') return notes; + if (selector === '#complete-lesson') return completeButton; + if (selector === '#save-notes') return saveNotes; + if (selector === '#toast') return toast; + return null; + }, + querySelectorAll(selector) { + if (selector === '[data-criterion]') return [criterion]; + if (selector === '[data-criterion]:checked') return criterion.checked ? [criterion] : []; + return []; + }, + }; + + const localStorage = storageFrom({ + 'pm01-state-v1': JSON.stringify({ + completed: [], notes: {}, criteria: {}, lastLesson: null, diagnostic: {}, ...legacyState, + }), + }); + + const window = { + PM01: { + flows: [], diagnostics: [], tools: [], + modules: [ + { id: 'm01', title: 'Модуль 1', duration: '1–2 ч', outcome: 'Результат 1', lessons: [ + { id: 'a', title: 'Урок A', thesis: 'Тезис A', minutes: 10, body: ['Текст'], model: 'Модель', practice: ['Шаг'], criteria: ['Есть доказательство'] }, + ] }, + { id: 'm02', title: 'Модуль 2', duration: '1–2 ч', outcome: 'Результат 2', lessons: [ + { id: 'b', title: 'Урок B', thesis: 'Тезис B', minutes: 10, body: ['Текст'], model: 'Модель', practice: ['Шаг'], criteria: ['Есть доказательство'] }, + ] }, + ], + }, + addEventListener() {}, + scrollTo() {}, + }; + const location = { hash: `#/${route}` }; + const context = { window, document, localStorage, location, console, Blob, URL, setTimeout() { return 1; }, clearTimeout() {} }; + vm.createContext(context); + vm.runInContext(appCode, context, { filename: 'app.js' }); + + return { main, completeButton, criterion, location, localStorage }; +} + +test('lesson completion clearly stays unavailable until every evidence criterion is checked', () => { + const { main } = runBaseApp('lesson/a'); + assert.match(main.innerHTML, /0\/1 выполнено/); + assert.match(main.innerHTML, /id="complete-lesson"[^>]*disabled/); +}); + +test('completing a ready lesson records progress and advances to the next lesson', () => { + const { completeButton, location, localStorage } = runBaseApp('lesson/a', { criteria: { a: [0] } }); + assert.equal(typeof completeButton.listeners.click, 'function'); + completeButton.listeners.click({ currentTarget: completeButton }); + const saved = JSON.parse(localStorage.getItem('pm01-state-v1')); + assert.deepEqual(saved.completed, ['a']); + assert.equal(location.hash, '#/lesson/b'); +}); + +test('course view presents one primary learning path', () => { + const { main } = runBaseApp('course'); + assert.match(main.innerHTML, /Основной путь/); + assert.doesNotMatch(main.innerHTML, /Не линейный курс/); +}); diff --git a/tests/learning-lab-completion-integrity.test.js b/tests/learning-lab-completion-integrity.test.js new file mode 100644 index 0000000..b64aad5 --- /dev/null +++ b/tests/learning-lab-completion-integrity.test.js @@ -0,0 +1,107 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const appCode = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); + +function storageFrom(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key) { return values.has(key) ? values.get(key) : null; }, + setItem(key, value) { values.set(key, String(value)); }, + }; +} + +function classListStub() { + return { add() {}, remove() {}, toggle() { return false; } }; +} + +function run(route, state) { + const main = { innerHTML: '', focus() {} }; + const sidebarProgress = { innerHTML: '' }; + const mobileNav = { classList: classListStub() }; + const menuButton = { addEventListener() {}, setAttribute() {} }; + const toast = { textContent: '', classList: classListStub() }; + + const document = { + querySelector(selector) { + if (selector === '#main') return main; + if (selector === '#sidebar-progress') return sidebarProgress; + if (selector === '#mobile-nav') return mobileNav; + if (selector === '#menu-button') return menuButton; + if (selector === '#toast') return toast; + return null; + }, + querySelectorAll() { return []; }, + }; + + const localStorage = storageFrom({ 'pm01-state-v1': JSON.stringify(state) }); + const lesson = { + id: 'lab-a', + title: 'Lab A', + thesis: 'Тезис', + minutes: 15, + body: [], + model: '', + practice: [], + criteria: [], + learningLab: { + skill: 'Проверять решение через обязательный кейс и рабочий инструмент.', + technique: { name: 'Technique', purpose: 'Purpose', steps: ['Step'], model: 'MODEL' }, + workedExample: { title: 'Example', steps: ['Example step'] }, + drills: [{ + id: 'd1', stage: 'cold', required: true, title: 'Case', situation: 'Situation', prompt: 'Decision?', + options: [{ id: 'ok', label: 'Option', feedback: 'Feedback', score: 3 }], + }], + workbookTitle: 'Workbook', + workbookFields: [{ id: 'field1', label: 'Evidence', prompt: 'Add evidence', required: true }], + transferPrompt: 'Apply to a real project.', + }, + }; + + const window = { + PM01: { + flows: [], diagnostics: [], tools: [], + modules: [{ id: 'm01', title: 'M01', duration: '1 ч', outcome: 'Outcome', lessons: [lesson] }], + }, + addEventListener() {}, scrollTo() {}, + }; + const location = { hash: `#/${route}` }; + const context = { window, document, localStorage, location, console, Blob, URL, setTimeout() { return 1; }, clearTimeout() {} }; + vm.createContext(context); + vm.runInContext(appCode, context, { filename: 'app.js' }); + return { main, sidebarProgress }; +} + +function baseState(overrides = {}) { + return { + completed: [], notes: {}, criteria: {}, lastLesson: null, diagnostic: {}, lab: {}, + ...overrides, + }; +} + +test('legacy completed flag does not bypass current Learning Lab evidence', () => { + const { main } = run('lesson/lab-a', baseState({ completed: ['lab-a'] })); + assert.match(main.innerHTML, /0\/1 решений · 0\/1 полей/); + assert.match(main.innerHTML, /id="complete-lesson"[^>]*disabled/); + assert.doesNotMatch(main.innerHTML, /Вернуться к программе →/); +}); + +test('course progress excludes a stale Learning Lab completion without current evidence', () => { + const { main, sidebarProgress } = run('course', baseState({ completed: ['lab-a'] })); + assert.match(main.innerHTML, /0%<\/strong>/); + assert.match(main.innerHTML, /0\/1<\/strong>/); + assert.match(sidebarProgress.innerHTML, />0% { + const state = baseState({ + completed: ['lab-a'], + lab: { 'lab-a': { drillAnswers: { d1: 'ok' }, workbook: { field1: 'fact' } } }, + }); + const { main } = run('course', state); + assert.match(main.innerHTML, /100%<\/strong>/); + assert.match(main.innerHTML, /1\/1<\/strong>/); +}); diff --git a/tests/m01-app-smoke.test.js b/tests/m01-app-smoke.test.js new file mode 100644 index 0000000..5ebef63 --- /dev/null +++ b/tests/m01-app-smoke.test.js @@ -0,0 +1,170 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const domain = require('../learning-domain.js'); +const baseAppCode = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); +const validationDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-data.js'), 'utf8'); +const validationAppCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-app.js'), 'utf8'); + +function storageFrom(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key) { return values.has(key) ? values.get(key) : null; }, + setItem(key, value) { values.set(key, String(value)); }, + removeItem(key) { values.delete(key); }, + }; +} + +function runValidationApp(route, initialStorage = {}) { + const main = { innerHTML: '', focus() {} }; + const moduleList = { + inserted: '', + insertAdjacentHTML(_position, html) { this.inserted = html; }, + }; + const document = { + querySelector(selector) { + if (selector === '#main') return main; + if (selector === '.module-list') return moduleList; + return null; + }, + querySelectorAll() { return []; }, + }; + const localStorage = storageFrom(initialStorage); + const window = { + PM01: {}, + PM01Learning: domain, + addEventListener() {}, + confirm() { return true; }, + }; + const context = { + window, + document, + localStorage, + location: { hash: `#/${route}` }, + queueMicrotask(callback) { callback(); }, + console, + Date, + }; + vm.createContext(context); + vm.runInContext(validationDataCode, context, { filename: 'm01-validation-data.js' }); + vm.runInContext(validationAppCode, context, { filename: 'm01-validation-app.js' }); + return { main, moduleList, localStorage }; +} + +function runHashchangeOwnershipHandoff() { + const main = { innerHTML: '', focus() {} }; + const sidebarProgress = { innerHTML: '' }; + const mobileNav = { classList: { remove() {}, toggle() { return false; } } }; + const menuButton = { addEventListener() {}, setAttribute() {} }; + const listeners = { hashchange: [] }; + const microtasks = []; + const document = { + querySelector(selector) { + if (selector === '#main') return main; + if (selector === '#sidebar-progress') return sidebarProgress; + if (selector === '#mobile-nav') return mobileNav; + if (selector === '#menu-button') return menuButton; + return null; + }, + querySelectorAll() { return []; }, + }; + const localStorage = storageFrom(); + const window = { + PM01: { modules: [], flows: [], diagnostics: [], tools: [] }, + PM01Learning: domain, + addEventListener(type, callback) { + if (!listeners[type]) listeners[type] = []; + listeners[type].push(callback); + }, + scrollTo() {}, + confirm() { return true; }, + }; + const context = { + window, + document, + localStorage, + location: { hash: '#/unknown' }, + queueMicrotask(callback) { microtasks.push(callback); }, + console, + Date, + }; + vm.createContext(context); + vm.runInContext(validationDataCode, context, { filename: 'm01-validation-data.js' }); + vm.runInContext(baseAppCode, context, { filename: 'app.js' }); + vm.runInContext(validationAppCode, context, { filename: 'm01-validation-app.js' }); + + assert.equal(listeners.hashchange.length, 2, 'base and extension hashchange handlers must both be registered'); + main.innerHTML = 'sentinel-before-validation'; + context.location.hash = '#/validation/m01'; + + listeners.hashchange[0](); + const afterBaseRouter = main.innerHTML; + + listeners.hashchange[1](); + while (microtasks.length) microtasks.shift()(); + + return { afterBaseRouter, afterExtension: main.innerHTML }; +} + +test('fresh validation route renders baseline without exposing treatment or post-case controls', () => { + const { main } = runValidationApp('validation/m01'); + + assert.match(main.innerHTML, /01 · Baseline/); + assert.match(main.innerHTML, /data-submit-assessment="baseline"/); + assert.doesNotMatch(main.innerHTML, /data-submit-assessment="postCase"/); +}); + +test('submitted baseline raw validation renderer exposes only the replaceable M01 learning placeholders', () => { + const validationState = { + version: 1, + baseline: { + answers: {}, + reasoning: 'Исходный диагноз уже был зафиксирован ранее.', + submittedAt: '2026-09-06T12:00:00.000Z', + score: { total: 5, max: 15, byDimension: {}, answered: 5 }, + }, + }; + const { main } = runValidationApp('validation/m01', { + 'pm01-validation-m01-v1': JSON.stringify(validationState), + }); + + assert.match(main.innerHTML, /#\/lesson\/project-system/); + assert.match(main.innerHTML, /#\/lesson\/system-diagnostic/); + assert.doesNotMatch(main.innerHTML, /data-submit-assessment="postCase"/); +}); + +test('submitted baseline stays blind: score and option feedback are hidden until post-case is complete', () => { + const validationState = { + version: 1, + baseline: { + answers: { 'baseline-mechanism': 'people' }, + reasoning: 'Я зафиксировал исходный диагноз до изучения материала.', + submittedAt: '2026-09-06T12:00:00.000Z', + score: { total: 0, max: 15, byDimension: { mechanism: 0 }, answered: 5 }, + }, + }; + const { main } = runValidationApp('validation/m01', { + 'pm01-validation-m01-v1': JSON.stringify(validationState), + }); + + assert.doesNotMatch(main.innerHTML, /итог по rubric/); + assert.doesNotMatch(main.innerHTML, /Это объясняет проблему качествами людей/); + assert.match(main.innerHTML, /результат скрыт до post-case/); +}); + +test('course route receives a single M01 validation CTA from the extension', () => { + const { moduleList } = runValidationApp('course'); + + assert.match(moduleList.inserted, /data-validation-cta/); + assert.match(moduleList.inserted, /#\/validation\/m01/); +}); + +test('hashchange hands validation/m01 to the extension without base-router DOM overwrite', () => { + const { afterBaseRouter, afterExtension } = runHashchangeOwnershipHandoff(); + + assert.equal(afterBaseRouter, 'sentinel-before-validation', 'base router must yield without writing #main'); + assert.match(afterExtension, /01 · Baseline/, 'validation extension must own and render the route'); +}); diff --git a/tests/m01-cold-drill-lock.test.js b/tests/m01-cold-drill-lock.test.js new file mode 100644 index 0000000..da2d652 --- /dev/null +++ b/tests/m01-cold-drill-lock.test.js @@ -0,0 +1,96 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const courseDataCode = fs.readFileSync(path.join(__dirname, '..', 'course-data.js'), 'utf8'); +const validationDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-data.js'), 'utf8'); +const labDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-learning-lab-data.js'), 'utf8'); +const appCode = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); +const validationAppCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-app.js'), 'utf8'); +const domain = require('../learning-domain.js'); + +function storageFrom(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key) { return values.has(key) ? values.get(key) : null; }, + setItem(key, value) { values.set(key, String(value)); }, + removeItem(key) { values.delete(key); }, + }; +} + +function drillInput(value) { + return { + value, + disabled: false, + dataset: { labDrill: 'm01-drill-system' }, + listeners: [], + addEventListener(type, callback) { + if (type === 'change') this.listeners.push(callback); + }, + }; +} + +function dispatchChange(input) { + if (input.disabled) return; + for (const callback of input.listeners) callback({ target: input, currentTarget: input }); +} + +test('M01 cold drill freezes the first choice after feedback instead of allowing answer replacement', () => { + const first = drillInput('escalate-dev'); + const second = drillInput('track-dependency'); + const main = { innerHTML: '', focus() {} }; + const sidebarProgress = { innerHTML: '' }; + const mobileNav = { classList: { remove() {}, toggle() { return false; } } }; + const menuButton = { addEventListener() {}, setAttribute() {} }; + + const document = { + querySelector(selector) { + if (selector === '#main') return main; + if (selector === '#sidebar-progress') return sidebarProgress; + if (selector === '#mobile-nav') return mobileNav; + if (selector === '#menu-button') return menuButton; + return null; + }, + querySelectorAll(selector) { + if (selector === '[data-lab-drill]') return [first, second]; + if (selector === '[data-lab-drill="m01-drill-system"]') return [first, second]; + return []; + }, + }; + const localStorage = storageFrom(); + const window = { + PM01Learning: domain, + addEventListener() {}, + scrollTo() {}, + confirm() { return true; }, + }; + const context = { + window, + document, + localStorage, + location: { hash: '#/lesson/project-system' }, + queueMicrotask(callback) { callback(); }, + console, + Blob, + URL, + Date, + }; + + vm.createContext(context); + vm.runInContext(courseDataCode, context, { filename: 'course-data.js' }); + vm.runInContext(validationDataCode, context, { filename: 'm01-validation-data.js' }); + vm.runInContext(labDataCode, context, { filename: 'm01-learning-lab-data.js' }); + vm.runInContext(appCode, context, { filename: 'app.js' }); + vm.runInContext(validationAppCode, context, { filename: 'm01-validation-app.js' }); + + dispatchChange(first); + assert.equal(first.disabled, true); + assert.equal(second.disabled, true); + + dispatchChange(second); + + const stored = JSON.parse(localStorage.getItem('pm01-state-v1')); + assert.equal(stored.lab['project-system'].drillAnswers['m01-drill-system'], 'escalate-dev'); +}); diff --git a/tests/m01-content-quality.test.js b/tests/m01-content-quality.test.js new file mode 100644 index 0000000..d290730 --- /dev/null +++ b/tests/m01-content-quality.test.js @@ -0,0 +1,63 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const courseDataCode = fs.readFileSync(path.join(__dirname, '..', 'course-data.js'), 'utf8'); +const validationDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-data.js'), 'utf8'); +const labDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-learning-lab-data.js'), 'utf8'); + +function loadM01() { + const context = { window: {}, console }; + vm.createContext(context); + vm.runInContext(courseDataCode, context, { filename: 'course-data.js' }); + vm.runInContext(validationDataCode, context, { filename: 'm01-validation-data.js' }); + vm.runInContext(labDataCode, context, { filename: 'm01-learning-lab-data.js' }); + const pm01 = context.window.PM01; + const lessons = pm01.modules.flatMap((module) => module.lessons || []); + return { + validation: pm01.m01Validation, + lessons: ['project-system', 'system-diagnostic'].map((id) => lessons.find((lesson) => lesson.id === id)), + }; +} + +test('M01.1 preserves the five validation dimensions', () => { + const { validation } = loadM01(); + assert.deepEqual( + Array.from(validation.rubricDimensions, (dimension) => dimension.id), + ['mechanism', 'evidence', 'tradeoffs', 'intervention', 'changeCondition'] + ); +}); + +test('every required M01 drill uses 3-4 plausible choices with a scored near-miss', () => { + const { lessons } = loadM01(); + for (const lesson of lessons) { + for (const drill of lesson.learningLab.drills.filter((item) => item.required !== false)) { + assert.ok(drill.options.length >= 3 && drill.options.length <= 4, `${drill.id} must have 3-4 options`); + assert.ok(drill.options.some((option) => Number(option.score) === 3), `${drill.id} must have a strongest option`); + assert.ok(drill.options.some((option) => Number(option.score) === 2), `${drill.id} must include a credible near-miss`); + } + } +}); + +test('each M01 workbook has six required fields including alternative hypothesis and falsifier', () => { + const { lessons } = loadM01(); + for (const lesson of lessons) { + const required = lesson.learningLab.workbookFields.filter((field) => field.required !== false); + assert.equal(required.length, 6, `${lesson.id} must have exactly six required workbook fields`); + const ids = new Set(required.map((field) => field.id)); + assert.ok(ids.has('alternative'), `${lesson.id} must require an alternative hypothesis`); + assert.ok(ids.has('falsifier'), `${lesson.id} must require a falsifying fact`); + } +}); + +test('M01 transfer requires a real decision, evidence and a condition for revising it', () => { + const { lessons } = loadM01(); + for (const lesson of lessons) { + const prompt = lesson.learningLab.transferPrompt.toLowerCase(); + assert.match(prompt, /решени/, `${lesson.id} transfer must require a real decision`); + assert.match(prompt, /(evidence|доказ|факт)/, `${lesson.id} transfer must require evidence`); + assert.match(prompt, /(пересмотр|измен|отмен|опроверг)/, `${lesson.id} transfer must define when to revise the decision`); + } +}); diff --git a/tests/m01-content.test.js b/tests/m01-content.test.js new file mode 100644 index 0000000..14d98b3 --- /dev/null +++ b/tests/m01-content.test.js @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const dataPath = path.join(__dirname, '..', 'm01-validation-data.js'); +const dataExists = fs.existsSync(dataPath); + +function loadValidation() { + const sandbox = { window: { PM01: {} } }; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(dataPath, 'utf8'), sandbox, { filename: 'm01-validation-data.js' }); + return sandbox.window.PM01.m01Validation; +} + +test('M01 validation content module exists', () => { + assert.equal(dataExists, true, 'm01-validation-data.js must exist'); +}); + +test('M01 validation covers five stable rubric dimensions', { skip: !dataExists }, () => { + const validation = loadValidation(); + assert.deepEqual( + Array.from(validation.rubricDimensions, (item) => item.id), + ['mechanism', 'evidence', 'tradeoffs', 'intervention', 'changeCondition'] + ); +}); + +test('baseline and post-case are non-identical five-question assessments using the same dimensions', { skip: !dataExists }, () => { + const validation = loadValidation(); + assert.notEqual(validation.baseline.id, validation.postCase.id); + assert.notEqual(validation.baseline.scenario, validation.postCase.scenario); + + for (const assessment of [validation.baseline, validation.postCase]) { + assert.equal(assessment.questions.length, 5); + assert.deepEqual( + Array.from(assessment.questions, (question) => question.dimension).sort(), + ['changeCondition', 'evidence', 'intervention', 'mechanism', 'tradeoffs'] + ); + for (const question of assessment.questions) { + assert.equal(question.options.length, 4); + for (const option of question.options) { + assert.equal(Number.isInteger(option.score), true); + assert.equal(option.score >= 0 && option.score <= 3, true); + assert.equal(typeof option.feedback, 'string'); + assert.equal(option.feedback.length > 20, true); + } + } + } +}); + +test('M01 validation includes two lesson drills plus field application and reflection', { skip: !dataExists }, () => { + const validation = loadValidation(); + assert.equal(validation.decisionDrills.length >= 2, true); + assert.deepEqual( + Array.from(validation.decisionDrills, (drill) => drill.lessonId).sort(), + ['project-system', 'system-diagnostic'] + ); + + const fieldIds = Array.from(validation.fieldApplication.fields, (field) => field.id); + for (const required of ['project', 'symptom', 'mechanism', 'intervention', 'signal', 'evidence', 'nextDecision']) { + assert.equal(fieldIds.includes(required), true, `missing field ${required}`); + } + assert.equal(validation.reflection.prompts.length >= 3, true); +}); diff --git a/tests/m01-learning-lab.test.js b/tests/m01-learning-lab.test.js new file mode 100644 index 0000000..36b4283 --- /dev/null +++ b/tests/m01-learning-lab.test.js @@ -0,0 +1,33 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const app = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); + +test('base state remains backward compatible while adding isolated lab state', () => { + assert.equal(app.includes('lab: {}'), true, 'default state must include an empty lab object'); + assert.equal(app.includes('pm01-state-v1'), true, 'existing storage key must remain unchanged'); +}); + +test('M01 lesson renderer exposes a decision-training sequence and workbook', () => { + assert.equal(app.includes('lesson.learningLab'), true, 'lesson renderer must branch on optional learningLab metadata'); + assert.equal(app.includes('class="learning-lab"'), true, 'learning lab wrapper missing'); + assert.equal(app.includes('data-lab-drill'), true, 'decision drill controls missing'); + assert.equal(app.includes('class="lab-feedback"'), true, 'immediate feedback region missing'); + assert.equal(app.includes('data-lab-field'), true, 'workbook persistence controls missing'); + assert.equal(app.includes('class="lab-transfer"'), true, 'real-project transfer section missing'); +}); + +test('M01 completion uses substantive lab readiness while legacy lessons keep criteria gate', () => { + assert.equal(app.includes('function labReady'), true, 'lab readiness helper missing'); + assert.equal(app.includes('requiredDrills'), true, 'lab readiness must account for required drills'); + assert.equal(app.includes('requiredFields'), true, 'lab readiness must account for required workbook fields'); + assert.equal(app.includes('data-criterion'), true, 'legacy criteria gate must remain available for M02-M10'); +}); + +test('lab interactions persist drill answers and workbook values in the existing lesson state', () => { + assert.equal(app.includes('drillAnswers'), true, 'drill answers must be persisted'); + assert.equal(app.includes('workbook'), true, 'workbook values must be persisted'); + assert.equal(app.includes('updateLabCompletionGate'), true, 'lab completion gate must refresh after interaction'); +}); diff --git a/tests/m01-simulator-domain.test.js b/tests/m01-simulator-domain.test.js new file mode 100644 index 0000000..1cb9dd0 --- /dev/null +++ b/tests/m01-simulator-domain.test.js @@ -0,0 +1,96 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function loadSimulator() { + const context = { window: {} }; + vm.createContext(context); + vm.runInContext(fs.readFileSync('m01-simulator-data.js', 'utf8'), context, { filename: 'm01-simulator-data.js' }); + vm.runInContext(fs.readFileSync('m01-simulator-domain.js', 'utf8'), context, { filename: 'm01-simulator-domain.js' }); + return { + mission: context.window.PM01SimulatorData.mission, + domain: context.window.PM01SimulatorDomain, + }; +} + +test('mission defines exactly four deterministic decision moments with 3-4 options each', () => { + const { mission } = loadSimulator(); + assert.equal(mission.id, 'm01-mission-partner-launch-v1'); + assert.equal(mission.decisions.length, 4); + for (const decision of mission.decisions) { + assert.ok(decision.options.length >= 3 && decision.options.length <= 4); + for (const option of decision.options) { + for (const value of Object.values(option.effects)) assert.equal(Number.isInteger(value), true); + } + } +}); + +test('initial run exposes four bounded meters and pinned treatment id', () => { + const { mission, domain } = loadSimulator(); + const run = domain.initialRun(mission); + assert.equal(run.treatmentId, 'm01-mission-partner-launch-v1'); + assert.deepEqual(JSON.parse(JSON.stringify(run.meters)), { deadline: 58, trust: 64, capacity: 72, risk: 63 }); + for (const value of Object.values(run.meters)) assert.ok(value >= 0 && value <= 100); +}); + +test('decision commits are append-only, immutable and clamp meter values', () => { + const { mission, domain } = loadSimulator(); + const run = domain.initialRun(mission); + const first = domain.commitDecision(run, mission, 'd1', 'decision-timeline', 'Need evidence first'); + assert.equal(first.decisions.length, 1); + assert.equal(first.decisions[0].optionId, 'decision-timeline'); + assert.equal(first.flags.timeline_reconstructed, true); + assert.throws(() => domain.commitDecision(first, mission, 'd1', 'hard-deadline', 'replace'), /already committed/i); + + const extremeMission = JSON.parse(JSON.stringify(mission)); + extremeMission.decisions[1].options[0].effects = { deadline: 1000, trust: -1000, capacity: 1000, risk: -1000 }; + const clamped = domain.commitDecision(first, extremeMission, 'd2', extremeMission.decisions[1].options[0].id, ''); + assert.deepEqual(JSON.parse(JSON.stringify(clamped.meters)), { deadline: 100, trust: 0, capacity: 100, risk: 0 }); +}); + +test('opening tools records evidence without changing project meters', () => { + const { mission, domain } = loadSimulator(); + const run = domain.initialRun(mission); + const opened = domain.openTool(run, 'decision-timeline', 'd1'); + assert.deepEqual(JSON.parse(JSON.stringify(opened.meters)), JSON.parse(JSON.stringify(run.meters))); + assert.equal(opened.toolsOpened.length, 1); + assert.equal(opened.toolsOpened[0].toolId, 'decision-timeline'); +}); + +test('required rationale contract rejects fewer than 8 trimmed characters without committing evidence', () => { + const { mission, domain } = loadSimulator(); + const run = domain.initialRun(mission); + assert.throws( + () => domain.commitDecision(run, mission, 'd1', 'decision-timeline', '1234567'), + /rationale/i, + ); + assert.equal(run.decisions.length, 0); + assert.equal(run.decisionIndex, 0); + const accepted = domain.commitDecision(run, mission, 'd1', 'decision-timeline', '12345678'); + assert.equal(accepted.decisions[0].rationale, '12345678'); +}); + +test('completion requires all four decisions and required rationales', () => { + const { mission, domain } = loadSimulator(); + let run = domain.initialRun(mission); + run = domain.commitDecision(run, mission, 'd1', 'decision-timeline', 'Need evidence first'); + run = domain.commitDecision(run, mission, 'd2', 'decision-contract', ''); + run = domain.commitDecision(run, mission, 'd3', 'split-decision', ''); + assert.equal(domain.isComplete(run, mission), false); + assert.throws(() => domain.commitDecision(run, mission, 'd4', 'revise-diagnosis', ''), /rationale/i); + assert.throws(() => domain.commitDecision(run, mission, 'd4', 'revise-diagnosis', 'short'), /rationale/i); + run = domain.commitDecision(run, mission, 'd4', 'revise-diagnosis', 'Security dependency changes the diagnosis'); + assert.equal(domain.isComplete(run, mission), true); +}); + +test('trajectory returns decision path and meter snapshots without aggregate score', () => { + const { mission, domain } = loadSimulator(); + let run = domain.initialRun(mission); + run = domain.commitDecision(run, mission, 'd1', 'decision-timeline', 'Need evidence first'); + const view = domain.trajectory(run, mission); + assert.equal(view.treatmentId, mission.id); + assert.equal(view.decisions.length, 1); + assert.equal(Object.prototype.hasOwnProperty.call(view, 'score'), false); + assert.ok(Array.isArray(view.meterHistory)); +}); diff --git a/tests/m01-simulator-integration.test.js b/tests/m01-simulator-integration.test.js new file mode 100644 index 0000000..02a14ba --- /dev/null +++ b/tests/m01-simulator-integration.test.js @@ -0,0 +1,63 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +function read(path) { + return fs.readFileSync(path, 'utf8'); +} + +test('index loads simulator runtime, routing and validation gate before validation extension', () => { + const html = read('index.html'); + const css = html.indexOf('m01-simulator.css'); + const data = html.indexOf('m01-simulator-data.js'); + const domain = html.indexOf('m01-simulator-domain.js'); + const app = html.indexOf('m01-simulator-app.js'); + const routing = html.indexOf('m01-simulator-routing.js'); + const gate = html.indexOf('m01-validation-simulator-gate.js'); + const validation = html.indexOf('m01-validation-app.js'); + assert.ok(css >= 0); + assert.ok(data >= 0 && domain > data && app > domain && routing > app && gate > routing && validation > gate); +}); + +test('M01 routing adapter sends course and validation entries to mission/m01', () => { + const routing = read('m01-simulator-routing.js'); + assert.match(routing, /#\/mission\/m01/); + assert.match(routing, /project-system/); + assert.match(routing, /system-diagnostic/); + assert.match(routing, /m01MissionEntry/); +}); + +test('simulator app owns mission route, isolated storage, semantic choices and focusable progression', () => { + const simulator = read('m01-simulator-app.js'); + assert.match(simulator, /pm01-sim-m01-v1/); + assert.match(simulator, /mission\/m01/); + assert.match(simulator, /
    { + const gate = read('m01-validation-simulator-gate.js'); + assert.match(gate, /pm01-sim-m01-v1/); + assert.match(gate, /m01-mission-partner-launch-v1/); + assert.match(gate, /missionVersion\s*=\s*1/); + assert.match(gate, /reviewReachedAt/); + assert.match(gate, /completedAt/); + assert.match(gate, /project-system/); + assert.match(gate, /system-diagnostic/); + assert.match(gate, /filter\(\(id\) => !m01LessonIds\.includes\(id\)\)/); +}); + +test('simulator styles include reduced-motion handling and visible meter semantics', () => { + const css = read('m01-simulator.css'); + assert.match(css, /prefers-reduced-motion/); + assert.match(css, /sim-meter/); + assert.match(css, /:focus-visible/); +}); + +test('pre-post trajectory review does not reveal preferred-answer labels', () => { + const simulator = read('m01-simulator-app.js'); + assert.doesNotMatch(simulator, /правильн(ый|ая|ое)|preferred answer|сильный ход/i); +}); diff --git a/tests/m01-validation-completion-integrity.test.js b/tests/m01-validation-completion-integrity.test.js new file mode 100644 index 0000000..5731e0c --- /dev/null +++ b/tests/m01-validation-completion-integrity.test.js @@ -0,0 +1,148 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const courseDataCode = fs.readFileSync(path.join(__dirname, '..', 'course-data.js'), 'utf8'); +const validationDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-data.js'), 'utf8'); +const labDataCode = fs.readFileSync(path.join(__dirname, '..', 'm01-learning-lab-data.js'), 'utf8'); +const gateCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-simulator-gate.js'), 'utf8'); +const validationAppCode = fs.readFileSync(path.join(__dirname, '..', 'm01-validation-app.js'), 'utf8'); +const domain = require('../learning-domain.js'); + +function storageFrom(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + _values: values, + getItem(key) { return values.has(key) ? values.get(key) : null; }, + setItem(key, value) { values.set(key, String(value)); }, + removeItem(key) { values.delete(key); }, + }; +} + +function validationHarness(initialStorage = {}) { + const main = { innerHTML: '', focus() {} }; + const document = { + querySelector(selector) { return selector === '#main' ? main : null; }, + querySelectorAll() { return []; }, + }; + const localStorage = storageFrom(initialStorage); + const window = { + PM01Learning: domain, + addEventListener() {}, + confirm() { return true; }, + }; + const context = { + window, document, localStorage, + location: { hash: '#/validation/m01' }, + queueMicrotask(callback) { callback(); }, + console, Date, + }; + vm.createContext(context); + vm.runInContext(courseDataCode, context, { filename: 'course-data.js' }); + vm.runInContext(validationDataCode, context, { filename: 'm01-validation-data.js' }); + vm.runInContext(labDataCode, context, { filename: 'm01-learning-lab-data.js' }); + vm.runInContext(gateCode, context, { filename: 'm01-validation-simulator-gate.js' }); + return { context, main, localStorage, window }; +} + +function submittedBaseline() { + return { + version: 1, + baseline: { + answers: {}, reasoning: 'Исходный диагноз уже зафиксирован до обучения.', + submittedAt: '2026-09-07T10:00:00.000Z', + score: { total: 5, max: 15, byDimension: {}, answered: 5 }, + }, + }; +} + +function completedSimulatorEnvelope(overrides = {}) { + const decisions = [ + { decisionId: 'd1', optionId: 'decision-timeline', rationale: 'Нужно сначала различить механизм.' }, + { decisionId: 'd2', optionId: 'decision-contract', rationale: '' }, + { decisionId: 'd3', optionId: 'split-decision', rationale: '' }, + { decisionId: 'd4', optionId: 'revise-diagnosis', rationale: 'Новый security blocker опровергает прежнюю модель.' }, + ]; + return { + treatmentId: 'm01-mission-partner-launch-v1', + missionVersion: 1, + screen: 'review', + startedAt: '2026-09-09T10:00:00.000Z', + completedAt: '2026-09-09T10:08:00.000Z', + reviewReachedAt: '2026-09-09T10:08:00.000Z', + run: { + treatmentId: 'm01-mission-partner-launch-v1', + missionVersion: 1, + status: 'decisions_complete', + decisionIndex: 4, + decisions, + meters: { deadline: 72, trust: 85, capacity: 67, risk: 17 }, + flags: { hypothesis_revised: true }, + toolsOpened: [], + events: [], + }, + ...overrides, + }; +} + +test('legacy M01 lesson completion cannot unlock simulator-treatment post-case', () => { + const legacyCourseState = { + completed: ['project-system', 'system-diagnostic'], + notes: {}, criteria: {}, lastLesson: null, diagnostic: {}, lab: {}, + }; + const { context, main, window } = validationHarness({ + 'pm01-validation-m01-v1': JSON.stringify(submittedBaseline()), + 'pm01-state-v1': JSON.stringify(legacyCourseState), + }); + vm.runInContext(validationAppCode, context, { filename: 'm01-validation-app.js' }); + + assert.equal(window.PM01SimulatorGate.isComplete(), false); + assert.doesNotMatch(main.innerHTML, /data-submit-assessment="postCase"/); +}); + +test('only exact completed simulator treatment with final review unlocks post-case', () => { + const { context, main, localStorage, window } = validationHarness({ + 'pm01-validation-m01-v1': JSON.stringify(submittedBaseline()), + 'pm01-sim-m01-v1': JSON.stringify(completedSimulatorEnvelope()), + }); + vm.runInContext(validationAppCode, context, { filename: 'm01-validation-app.js' }); + + assert.equal(window.PM01SimulatorGate.isComplete(), true); + assert.match(main.innerHTML, /data-submit-assessment="postCase"/); + assert.equal(localStorage._values.has('pm01-state-v1'), false, 'gate must not persist synthetic legacy course evidence'); +}); + +test('simulator treatment does not unlock post-case before final trajectory review is reached', () => { + const incompleteReview = completedSimulatorEnvelope({ reviewReachedAt: null, completedAt: null, screen: 'consequence' }); + const { context, main, window } = validationHarness({ + 'pm01-validation-m01-v1': JSON.stringify(submittedBaseline()), + 'pm01-sim-m01-v1': JSON.stringify(incompleteReview), + }); + vm.runInContext(validationAppCode, context, { filename: 'm01-validation-app.js' }); + + assert.equal(window.PM01SimulatorGate.isComplete(), false); + assert.doesNotMatch(main.innerHTML, /data-submit-assessment="postCase"/); +}); + +test('simulator treatment rejects wrong version or broken D1/D4 rationale evidence', () => { + const wrongVersion = completedSimulatorEnvelope({ missionVersion: 2 }); + const { context: versionContext, main: versionMain, window: versionWindow } = validationHarness({ + 'pm01-validation-m01-v1': JSON.stringify(submittedBaseline()), + 'pm01-sim-m01-v1': JSON.stringify(wrongVersion), + }); + vm.runInContext(validationAppCode, versionContext, { filename: 'm01-validation-app.js' }); + assert.equal(versionWindow.PM01SimulatorGate.isComplete(), false); + assert.doesNotMatch(versionMain.innerHTML, /data-submit-assessment="postCase"/); + + const missingRationale = completedSimulatorEnvelope(); + missingRationale.run.decisions[3].rationale = ''; + const { context: rationaleContext, main: rationaleMain, window: rationaleWindow } = validationHarness({ + 'pm01-validation-m01-v1': JSON.stringify(submittedBaseline()), + 'pm01-sim-m01-v1': JSON.stringify(missingRationale), + }); + vm.runInContext(validationAppCode, rationaleContext, { filename: 'm01-validation-app.js' }); + assert.equal(rationaleWindow.PM01SimulatorGate.isComplete(), false); + assert.doesNotMatch(rationaleMain.innerHTML, /data-submit-assessment="postCase"/); +}); diff --git a/tests/static-contract.test.js b/tests/static-contract.test.js new file mode 100644 index 0000000..dbaf52e --- /dev/null +++ b/tests/static-contract.test.js @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function read(name) { + const file = path.join(__dirname, '..', name); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; +} + +const html = read('index.html'); +const baseApp = read('app.js'); +const validationApp = read('m01-validation-app.js'); +const validationStyles = read('m01-validation.css'); +const labData = read('m01-learning-lab-data.js'); +const artDirection = read('art-direction.css'); + +test('index loads validation styles and data, domain, base app, then validation extension in order', () => { + assert.notEqual(html.indexOf('href="m01-validation.css"'), -1, 'missing isolated validation stylesheet'); + + const scripts = ['course-data.js', 'm01-validation-data.js', 'learning-domain.js', 'app.js', 'm01-validation-app.js']; + let lastIndex = -1; + for (const script of scripts) { + const index = html.indexOf(`src="${script}"`); + assert.notEqual(index, -1, `missing ${script}`); + assert.equal(index > lastIndex, true, `${script} must load after previous script`); + lastIndex = index; + } +}); + +test('base router reserves the M01 validation route instead of rendering not-found before the extension', () => { + assert.equal(baseApp.includes('route: "validation-m01"'), true, 'base router must recognize validation/m01'); + assert.equal(baseApp.includes('if (route === "validation-m01")'), true, 'base renderer must yield validation/m01 to the extension'); +}); + +test('validation extension owns the M01 validation route and course CTA', () => { + assert.equal(validationApp.includes("validation/m01"), true, 'missing validation route'); + assert.equal(validationApp.includes('data-validation-cta'), true, 'missing M01 validation CTA contract'); +}); + +test('validation uses an isolated storage key so legacy app saves cannot erase experiment work', () => { + assert.equal(validationApp.includes('pm01-validation-m01-v1'), true, 'missing isolated validation storage key'); +}); + +test('validation UI uses semantic assessment controls and live result feedback', () => { + assert.equal(validationApp.includes(' validationIndex, true, 'lab data must load after validation data'); + assert.equal(labIndex < appIndex, true, 'lab data must load before app.js'); +}); + +test('M01 learning lab data targets the two M01 lessons and keeps stable cold drill ids', () => { + assert.equal(labData.includes('project-system'), true, 'project-system lab contract missing'); + assert.equal(labData.includes('system-diagnostic'), true, 'system-diagnostic lab contract missing'); + assert.equal(labData.includes('m01-drill-system'), true, 'project-system cold drill id must stay stable'); + assert.equal(labData.includes('m01-drill-diagnostic'), true, 'system-diagnostic cold drill id must stay stable'); + assert.equal(labData.includes('workbookFields'), true, 'lab workbook field contract missing'); + assert.equal(labData.includes('transferPrompt'), true, 'lab transfer contract missing'); +}); + +test('Editorial Instrument exposes readable learner text tokens and dedicated learning-lab styles', () => { + assert.equal(artDirection.includes('--text-secondary:'), true, 'readable secondary text token missing'); + assert.equal(artDirection.includes('--text-tertiary:'), true, 'readable tertiary text token missing'); + assert.equal(artDirection.includes('.learning-lab'), true, 'learning-lab visual contract missing'); + assert.equal(artDirection.includes('.lab-feedback'), true, 'lab feedback visual contract missing'); + assert.equal(artDirection.includes('.lab-workbook'), true, 'lab workbook visual contract missing'); + assert.equal(artDirection.includes(':focus-visible'), true, 'visible keyboard focus contract missing'); +}); diff --git a/v1-1.css b/v1-1.css new file mode 100644 index 0000000..b30e51a --- /dev/null +++ b/v1-1.css @@ -0,0 +1,236 @@ +:root { + --ink: #1d1d1f; + --ink-2: #ffffff; + --panel: #f5f5f7; + --paper: #ffffff; + --paper-2: #f5f5f7; + --muted: #6e6e73; + --accent: #0071e3; + --accent-2: #0071e3; + --blue: #0071e3; + --green: #188038; + --red: #d70015; + --line: rgba(0, 0, 0, .08); + --paper-line: rgba(0, 0, 0, .12); + --sidebar: 248px; + --radius: 24px; + color: var(--ink); + background: #f5f5f7; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; +} + +html { background: #f5f5f7; } +body { background: #f5f5f7; color: var(--ink); } +a { color: inherit; } + +.sidebar { + width: var(--sidebar); + padding: 28px 22px; + border-right: 1px solid rgba(0,0,0,.08); + background: rgba(255,255,255,.86); + color: var(--ink); + -webkit-backdrop-filter: saturate(180%) blur(24px); + backdrop-filter: saturate(180%) blur(24px); +} +.brand { gap: 10px; } +.brand-mark { + width: 42px; + height: 42px; + border-radius: 12px; + background: var(--ink); + color: #fff; + font: 650 12px ui-monospace, SFMono-Regular, Menlo, monospace; +} +.brand strong { color: var(--ink); font-size: 20px; letter-spacing: -.035em; } +.brand small { color: var(--muted); font: 600 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .06em; } +.main-nav { margin-top: 46px; gap: 4px; } +.main-nav a { + padding: 11px 12px; + color: #515154; + border-radius: 12px; + font-size: 14px; + font-weight: 600; +} +.main-nav a span { color: #a1a1a6; font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.main-nav a:hover { background: #f2f2f4; color: var(--ink); } +.main-nav a.active { background: #e8f1fb; color: #0066cc; } +.main-nav a.active span { color: #0066cc; } +.sidebar-progress { border-color: var(--line); background: #f7f7f9; } +.sidebar-progress .label-row { color: var(--muted); } +.progress-track { background: #e5e5ea; } +.progress-fill { background: var(--accent); } +.sidebar-note { color: #a1a1a6; font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } + +main { margin-left: var(--sidebar); background: #f5f5f7; } +.page { max-width: 1180px; padding: 68px 64px 120px; animation: v11-enter .28s ease; } +@keyframes v11-enter { from { opacity: 0; transform: translateY(6px); } } + +.eyebrow { + color: #0066cc; + font: 650 12px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: .01em; + text-transform: none; +} +h1, h2, h3 { color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; } +h1 { font-weight: 720; letter-spacing: -.055em; line-height: .98; } +h2 { font-weight: 700; letter-spacing: -.04em; } +h3 { font-weight: 680; } +.lead { color: #6e6e73; font-size: clamp(18px, 2vw, 22px); line-height: 1.5; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.muted { color: var(--muted); } +.accent { color: var(--ink); } +.section { margin-top: 84px; } +.section-heading p { color: var(--muted); line-height: 1.55; } + +.hero { padding: 34px 0 20px; } +.hero::after { display: none; } +.hero h1 { max-width: 900px; font-size: clamp(48px, 7vw, 86px); } +.hero .lead { max-width: 720px; } +.hero-actions { gap: 10px; margin-top: 34px; } + +.button { + min-height: 44px; + padding: 0 18px; + border-color: rgba(0,0,0,.12); + border-radius: 999px; + background: rgba(255,255,255,.72); + color: var(--ink); + font-weight: 650; + box-shadow: 0 1px 1px rgba(0,0,0,.02); + transition: transform .16s ease, background .16s ease, border-color .16s ease, box-shadow .16s ease; +} +.button:hover { transform: translateY(-1px); border-color: rgba(0,0,0,.18); background: #fff; box-shadow: 0 4px 14px rgba(0,0,0,.06); } +.button.primary { border-color: #0071e3; background: #0071e3; color: white; } +.button.primary:hover { background: #0077ed; border-color: #0077ed; } +.button:disabled { opacity: .42; cursor: not-allowed; transform: none; box-shadow: none; } +.button.subtle { min-height: 38px; color: #0066cc; background: transparent; border-color: transparent; } +.button.subtle:hover { background: #e8f1fb; border-color: transparent; } + +.stat-grid { grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 54px; border: 0; background: transparent; } +.stat { min-height: 122px; padding: 24px; border: 1px solid var(--line); border-radius: 20px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.03); } +.stat strong { color: var(--ink); font-size: 30px; } +.stat span { color: var(--muted); } + +.system-map { gap: 10px; } +.system-node { min-height: 120px; padding: 18px 14px; border: 1px solid var(--line); border-top: 1px solid var(--line); border-radius: 18px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.03); } +.system-node::before { content: ""; display: block; width: 28px; height: 4px; border-radius: 99px; background: var(--node); } +.system-node span { margin-top: 14px; color: var(--muted); font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +.system-node strong { color: var(--ink); } + +.next-card { border: 1px solid var(--line); border-radius: 28px; background: #fff; color: var(--ink); box-shadow: 0 8px 30px rgba(0,0,0,.05); } +.next-card .eyebrow { color: #0066cc !important; } +.next-card .next-meta { border-left-color: var(--line); } +.next-card .button { border-color: #0071e3; background: #0071e3; color: #fff; } + +.module-grid { gap: 18px; } +.module-card { min-height: 250px; padding: 30px; border-color: var(--line); border-radius: 24px; background: #fff; color: var(--ink); box-shadow: 0 2px 10px rgba(0,0,0,.035); } +.module-card::after { color: rgba(0,0,0,.025); } +.module-card .module-kicker { color: #0066cc; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.module-card h3 { color: var(--ink); } +.module-card p { color: var(--muted); } +.module-footer a { color: #0066cc; } +.module-footer a:hover { color: #004f9f; } + +.course-intro { gap: 42px; align-items: center; } +.course-intro > div:first-child { max-width: 740px; } +.course-metrics { padding: 26px; border-color: var(--line); border-radius: 22px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.03); } +.course-metrics strong { color: var(--ink); } +.course-metrics p { color: var(--muted); } +.path-note { display: flex; flex-wrap: wrap; gap: 10px 20px; margin-top: 38px; padding: 18px 20px; border-radius: 18px; background: #e8f1fb; color: #1d1d1f; font-size: 13px; } +.path-note strong { width: 100%; } +.path-note span { color: #515154; } +.module-list { gap: 12px; margin-top: 28px; border: 0; } +.module-row { min-height: 118px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 20px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.025); } +.module-row.current { border-color: rgba(0,113,227,.38); box-shadow: 0 0 0 3px rgba(0,113,227,.08); } +.module-row.completed { opacity: .76; } +.module-index { color: #a1a1a6; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.module-status { display: inline-flex; margin-bottom: 6px; color: #6e6e73; font-size: 11px; font-weight: 700; } +.module-row.current .module-status { color: #0066cc; } +.module-row h2 { color: var(--ink); } +.module-row p { color: var(--muted); } +.module-row a { color: #0066cc; } +.module-row a:hover { color: #004f9f; } + +.lesson-layout { grid-template-columns: minmax(0, 760px) 230px; gap: 48px; } +.lesson-layout > article { padding: clamp(28px, 5vw, 54px); border: 1px solid var(--line); border-radius: 30px; background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,.04); } +.lesson-header { border-bottom-color: var(--line); } +.lesson-header .meta { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.lesson-block p, .lesson-block li { color: #424245; font-size: 17px; line-height: 1.72; } +.insight { border: 0; border-radius: 18px; background: #f5f5f7; color: var(--ink); font-size: 21px; font-weight: 580; } +.model-card { border-color: var(--line); border-radius: 18px; background: #f5f5f7; color: #424245; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.practice { padding: 30px; border: 1px solid var(--line); border-radius: 22px; background: #f5f5f7; color: var(--ink); } +.practice .eyebrow { color: #0066cc; } +.practice-help, .field-help { color: var(--muted); font-size: 13px; line-height: 1.55; } +.field-help { display: block; margin-top: 4px; font-weight: 400; } +.criterion { padding: 13px 14px; border: 1px solid rgba(0,0,0,.06); border-radius: 13px; background: #fff; } +.criterion:has(input:checked) { border-color: rgba(0,113,227,.28); background: #f0f7ff; } +.criterion input { accent-color: #0071e3; } +.completion-status { margin: 14px 0 18px; color: #6e6e73; font-size: 13px; font-weight: 600; } +.completion-status.ready { color: #188038; } +.notes { border-color: var(--paper-line); border-radius: 14px; background: #fff; color: var(--ink); } +.notes:focus { outline: 3px solid rgba(0,113,227,.16); border-color: #0071e3; } +.practice .button { border-color: rgba(0,0,0,.12); color: var(--ink); background: #fff; } +.practice .button.primary { border-color: #0071e3; background: #0071e3; color: #fff; } +.lesson-aside .toc { border-color: var(--line); border-radius: 18px; background: rgba(255,255,255,.7); } +.toc small { color: #a1a1a6; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.toc a { color: #515154; } +.toc a:hover { color: #0066cc; } + +.question-card, .tool-card, .principle { border-color: var(--line); background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(0,0,0,.025); } +.question-card legend, .tool-card h3, .principle strong { color: var(--ink); } +.option { border-color: var(--line); color: #515154; background: #fff; } +.option:has(input:checked) { border-color: rgba(0,113,227,.42); background: #f0f7ff; color: var(--ink); } +.option input { accent-color: #0071e3; } +.diagnostic-result { border: 1px solid var(--line); background: #fff; color: var(--ink); box-shadow: 0 8px 30px rgba(0,0,0,.045); } +.result-empty, .tool-card p, .principle p { color: var(--muted); } + +.validation-shell { max-width: 900px; } +.validation-hero { border-bottom-color: var(--line); } +.validation-note { border: 0; border-radius: 16px; background: #eef5fc; color: #515154; } +.validation-step { border-color: var(--line); border-radius: 24px; background: #fff; color: var(--ink); box-shadow: 0 2px 14px rgba(0,0,0,.035); } +.validation-step > header p { color: var(--muted); } +.validation-scenario { border-radius: 16px; background: #f5f5f7; color: #424245; } +.validation-option { border-color: var(--line); color: #515154; background: #fff; } +.validation-option:has(input:checked) { border-color: rgba(0,113,227,.4); background: #f0f7ff; color: var(--ink); } +.validation-option input { accent-color: #0071e3; } +.validation-feedback { color: var(--muted); } +.validation-reasoning, .validation-evidence textarea { border-color: var(--line); border-radius: 14px; background: #fff; color: var(--ink); } +.validation-reasoning:focus, .validation-evidence textarea:focus { outline: 3px solid rgba(0,113,227,.16); border-color: #0071e3; } +.validation-score > div, .validation-drill { border-color: var(--line); background: #f7f7f9; } +.validation-drill p, .validation-cta p { color: var(--muted); } +.validation-state { border-color: var(--line); color: #515154; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.validation-result { border: 1px solid var(--line); background: #fff; color: var(--ink); } +/* M01 validation is a research checkpoint, not a second learner-facing track. */ +.validation-cta { display: none; } + +.toast { border: 1px solid rgba(0,0,0,.08); background: rgba(29,29,31,.92); color: white; box-shadow: 0 12px 40px rgba(0,0,0,.2); } + +@media (max-width: 980px) { + :root { --sidebar: 220px; } + .page { padding: 54px 36px 96px; } + .system-map { grid-template-columns: repeat(4, 1fr); } + .module-row { grid-template-columns: 54px minmax(190px,.9fr) 1.2fr 100px; gap: 16px; } +} + +@media (max-width: 760px) { + .sidebar { display: none; } + main { margin-left: 0; padding-top: 64px; } + .mobile-header { display: flex; position: fixed; inset: 0 0 auto; z-index: 20; height: 64px; padding: 0 18px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.88); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); } + .mobile-header .brand-mark { width: 34px; height: 34px; border-radius: 10px; } + .menu-button { border: 0; border-radius: 999px; padding: 8px 12px; background: #f2f2f4; color: var(--ink); font-weight: 650; } + .mobile-nav { top: 64px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); color: var(--ink); } + .mobile-nav a { color: #424245; } + .page { padding: 34px 18px 72px; } + .hero h1 { font-size: clamp(44px, 14vw, 66px); } + .stat-grid, .module-grid, .course-intro, .lesson-layout, .diagnostic-grid { grid-template-columns: 1fr; } + .system-map { grid-template-columns: repeat(2, 1fr); } + .next-card { grid-template-columns: 1fr; } + .next-card .next-meta { border-left: 0; border-top: 1px solid var(--line); gap: 20px; } + .module-row { grid-template-columns: 42px 1fr; gap: 10px 14px; padding: 18px; } + .module-row > p, .module-row > a { grid-column: 2; text-align: left; } + .lesson-layout > article { padding: 24px 20px; border-radius: 24px; } + .lesson-aside { position: static; } + .lesson-aside .toc { display: none; } + .practice { padding: 22px 18px; } + .path-note { display: grid; } +}