tessera-learn 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -37,7 +37,7 @@ pnpm tessera export <name> # each course exports independently to its own LMS p
37
37
  cd courses/<name> && pnpm exec tessera export # …this works for every command, not just dev
38
38
  ```
39
39
 
40
- A **bare command at the workspace root errors** and lists the available courses — it never silently picks one, so its meaning can't change as you add courses. Name the course, or `cd` into its folder. (The scaffolded root scripts — `pnpm dev`, `pnpm export`, … — target the seed course `starter-course`.)
40
+ A **bare command at the workspace root errors** and lists the available courses — it never silently picks one, so its meaning can't change as you add courses. Name the course, or `cd` into its folder. (The scaffolded root scripts — `pnpm dev`, `pnpm export`, … — pass straight through, so `pnpm dev <course>` runs that course and a bare `pnpm dev` errors just the same.)
41
41
 
42
42
  ### Sharing across courses with `$shared`
43
43
 
@@ -58,21 +58,21 @@ A **bare command at the workspace root errors** and lists the available courses
58
58
 
59
59
  ## Running the project
60
60
 
61
- From the project root (the project is set up for `pnpm` — Node's corepack provisions it automatically):
61
+ From the workspace root (set up for `pnpm` — Node's corepack provisions it automatically). The `dev`/`export`/`validate`/`check` scripts take the course to run; a bare command lists the workspace's courses rather than picking one:
62
62
 
63
63
  ```bash
64
64
  pnpm install # first time only
65
- pnpm dev # dev server at http://localhost:5173 (Ctrl+C to stop)
66
- pnpm export # build + package for the LMS standard configured in course.config.js
67
- pnpm validate # run project validation only — no server, no bundle
68
- pnpm check # validate, then the runtime accessibility audit (axe) over the built course
65
+ pnpm dev <course> # dev server at http://localhost:5173 (Ctrl+C to stop)
66
+ pnpm export <course> # build + package for the LMS standard configured in course.config.js
67
+ pnpm validate <course> # run project validation only — no server, no bundle
68
+ pnpm check <course> # validate, then the runtime accessibility audit (axe) over the built course
69
69
  ```
70
70
 
71
71
  The dev server hot-reloads as you edit pages, layouts, components, and `course.config.js`. The `export` command produces a SCORM 1.2, SCORM 2004, cmi5, or static-web bundle depending on `course.config.js`.
72
72
 
73
- `pnpm validate` runs the same checks as `dev` and `export` (page syntax, manifest shape, `pageConfig`, question components, asset references, LMS data-contract bypass, and the static accessibility rules) and exits non-zero if any fail. Use it as a fast feedback loop after editing — it's the quickest way to confirm a change is structurally sound.
73
+ `pnpm validate <course>` runs the same checks as `dev` and `export` (page syntax, manifest shape, `pageConfig`, question components, asset references, LMS data-contract bypass, and the static accessibility rules) and exits non-zero if any fail. Use it as a fast feedback loop after editing — it's the quickest way to confirm a change is structurally sound.
74
74
 
75
- `pnpm check` runs `validate` and then the deeper, opt-in pass (`tessera a11y`): it builds the course, renders every page in a headless browser, and runs [axe-core](https://github.com/dequelabs/axe-core) to catch issues a static scan can't see (computed ARIA, real rendered contrast). The runtime audit drives Playwright, which needs a browser binary once per machine:
75
+ `pnpm check <course>` runs `validate` and then the deeper, opt-in pass (`tessera a11y`): it builds the course, renders every page in a headless browser, and runs [axe-core](https://github.com/dequelabs/axe-core) to catch issues a static scan can't see (computed ARIA, real rendered contrast). The runtime audit drives Playwright, which needs a browser binary once per machine:
76
76
 
77
77
  ```bash
78
78
  pnpm exec playwright install chromium
@@ -892,7 +892,7 @@ export default {
892
892
 
893
893
  ### Build output
894
894
 
895
- `pnpm export` (which wraps `vite build`) writes:
895
+ `pnpm export <course>` (which wraps `vite build`) writes:
896
896
 
897
897
  | `export.standard` | What ships | Where |
898
898
  | ----------------- | ------------------------------------- | ---------------------------------------- |
@@ -905,7 +905,7 @@ For LMS exports, upload the zip via your LMS's import flow. For web export, the
905
905
 
906
906
  ### Validation
907
907
 
908
- The Vite plugin runs project validation on every dev start and build (page syntax, manifest shape, `pageConfig` parseability, question components, asset references, LMS data-contract bypass, etc.). Errors abort the build and print as `[tessera error] ...`; warnings print as `[tessera warning] ...` and don't block. Run `pnpm validate` to check without building.
908
+ The Vite plugin runs project validation on every dev start and build (page syntax, manifest shape, `pageConfig` parseability, question components, asset references, LMS data-contract bypass, etc.). Errors abort the build and print as `[tessera error] ...`; warnings print as `[tessera warning] ...` and don't block. Run `pnpm validate <course>` to check without building.
909
909
 
910
910
  ---
911
911
 
@@ -915,7 +915,7 @@ Tessera checks accessibility in two passes, plus components that are accessible
915
915
 
916
916
  **Static checks** run inside `validate`, `dev`, and `export` — no extra setup. They cover what's visible in your source: `<Image>` alt-or-`decorative`, `<Video>`/`<Audio>` `title` + captions/transcript, empty question option/answer labels, skipped heading levels (e.g. `h2` → `h4`), `branding.primaryColor` contrast against white, and a well-formed `language` tag. They also route the Svelte compiler's own `a11y_*` warnings through the reporter. Each diagnostic carries a rule ID in brackets (e.g. `[tessera/image-alt]`, `[a11y_missing_attribute]`) — that ID is what `a11y.ignore` and `a11y.level` match.
917
917
 
918
- **Runtime audit** is the opt-in deep pass: `pnpm a11y` (run it directly, or via `pnpm check`, which runs `validate` first) builds the course, renders **every** page in a headless browser (including pages gated behind a quiz), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json`, and exits non-zero on any violation at or above an impact threshold (default `serious`). It catches what a static scan can't — computed ARIA, focus order, real rendered contrast.
918
+ **Runtime audit** is the opt-in deep pass: `pnpm a11y <course>` (run it directly, or via `pnpm check <course>`, which runs `validate` first) builds the course, renders **every** page in a headless browser (including pages gated behind a quiz), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json`, and exits non-zero on any violation at or above an impact threshold (default `serious`). It catches what a static scan can't — computed ARIA, focus order, real rendered contrast.
919
919
 
920
920
  The runtime audit drives Playwright, which needs a browser binary once per machine:
921
921
 
@@ -1446,7 +1446,7 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1446
1446
  const disableRules = axeIgnoreRules(settings.ignore);
1447
1447
  const manifest = generateManifest(resolve(projectRoot, "pages"));
1448
1448
  const vite = await import("vite");
1449
- const { resolveTesseraConfig } = await import("./inline-config-CroQ-_2Y.js");
1449
+ const { resolveTesseraConfig } = await import("./inline-config-Dudu5r8w.js");
1450
1450
  const auditBaseConfig = await resolveTesseraConfig(projectRoot, workspaceRoot, {
1451
1451
  command: "build",
1452
1452
  mode: "production"
@@ -1510,23 +1510,29 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1510
1510
  nodes: v.nodes.length
1511
1511
  }));
1512
1512
  };
1513
- const navCount = await page.locator("button.tessera-nav-page").count();
1514
- if (navCount === 0) pages.push({
1515
- index: 0,
1516
- title: manifest.pages[0]?.title ?? "(entry)",
1517
- violations: await scan()
1518
- });
1519
- else for (let i = 0; i < navCount; i++) {
1520
- const btn = page.locator("button.tessera-nav-page").nth(i);
1521
- const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
1522
- await btn.click();
1523
- await page.waitForFunction((idx) => document.querySelectorAll("button.tessera-nav-page")[idx]?.getAttribute("aria-current") === "page", i, { timeout: 2e4 });
1524
- await page.waitForLoadState("networkidle");
1525
- pages.push({
1526
- index: i,
1513
+ const recordPage = async (index, title) => {
1514
+ const loadFailed = await page.evaluate(() => document.getElementById("tessera-app")?.dataset.tesseraPageError === "true");
1515
+ if (loadFailed) return {
1516
+ index,
1517
+ title,
1518
+ violations: [],
1519
+ loadFailed
1520
+ };
1521
+ return {
1522
+ index,
1527
1523
  title,
1528
1524
  violations: await scan()
1529
- });
1525
+ };
1526
+ };
1527
+ const totalPages = manifest.pages.length;
1528
+ if (!await page.evaluate(() => typeof window.__tesseraAudit?.goToIndex === "function")) {
1529
+ if (totalPages > 1) console.warn(`\x1b[33m[tessera a11y]\x1b[0m Could not enumerate pages; auditing the entry page only (1 of ${totalPages}). The report records the reduced scope.`);
1530
+ pages.push(await recordPage(0, manifest.pages[0]?.title ?? "(entry)"));
1531
+ } else for (let i = 0; i < totalPages; i++) {
1532
+ await page.evaluate((idx) => window.__tesseraAudit.goToIndex(idx), i);
1533
+ await page.waitForFunction((idx) => document.getElementById("tessera-app")?.dataset.tesseraPageIndex === String(idx), i, { timeout: 2e4 });
1534
+ await page.waitForLoadState("networkidle");
1535
+ pages.push(await recordPage(i, manifest.pages[i]?.title ?? `Page ${i + 1}`));
1530
1536
  }
1531
1537
  } finally {
1532
1538
  await browser.close();
@@ -1534,17 +1540,24 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1534
1540
  const thresholdRank = IMPACT_RANK[threshold];
1535
1541
  let totalViolations = 0;
1536
1542
  let failingViolations = 0;
1537
- for (const p of pages) for (const v of p.violations) {
1538
- totalViolations++;
1539
- if (isFailing(v, thresholdRank)) failingViolations++;
1543
+ let pagesFailedToLoad = 0;
1544
+ for (const p of pages) {
1545
+ if (p.loadFailed) pagesFailedToLoad++;
1546
+ for (const v of p.violations) {
1547
+ totalViolations++;
1548
+ if (isFailing(v, thresholdRank)) failingViolations++;
1549
+ }
1540
1550
  }
1541
1551
  const report = {
1542
1552
  standard: settings.standard,
1543
1553
  threshold,
1544
1554
  pages,
1555
+ pagesAudited: pages.length,
1556
+ totalPages: manifest.pages.length,
1557
+ pagesFailedToLoad,
1545
1558
  totalViolations,
1546
1559
  failingViolations,
1547
- passed: failingViolations === 0
1560
+ passed: failingViolations === 0 && pagesFailedToLoad === 0
1548
1561
  };
1549
1562
  const reportPath = resolve(projectRoot, "a11y-report.json");
1550
1563
  writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
@@ -1562,6 +1575,10 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1562
1575
  function printSummary(report, reportPath) {
1563
1576
  const thresholdRank = IMPACT_RANK[report.threshold];
1564
1577
  for (const p of report.pages) {
1578
+ if (p.loadFailed) {
1579
+ console.log(`\x1b[31m ✗\x1b[0m ${p.title} — failed to load`);
1580
+ continue;
1581
+ }
1565
1582
  if (p.violations.length === 0) {
1566
1583
  console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
1567
1584
  continue;
@@ -1571,10 +1588,20 @@ function printSummary(report, reportPath) {
1571
1588
  for (const v of p.violations) console.log(` [${v.impact ?? "n/a"}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? "" : "s"})`);
1572
1589
  }
1573
1590
  console.log(`\n[tessera a11y] Report written to ${reportPath}`);
1591
+ if (report.pagesAudited < report.totalPages) console.log(`\x1b[33m[tessera a11y] Covered ${report.pagesAudited} of ${report.totalPages} page(s)\x1b[0m — reduced scope, the rest were not audited.`);
1592
+ else if (report.pagesFailedToLoad > 0) {
1593
+ const scanned = report.pagesAudited - report.pagesFailedToLoad;
1594
+ console.log(`[tessera a11y] Reached all ${report.totalPages} page(s); scanned ${scanned}, ${report.pagesFailedToLoad} failed to load.`);
1595
+ } else console.log(`[tessera a11y] Covered all ${report.totalPages} page(s).`);
1574
1596
  if (report.passed) console.log(`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`);
1575
- else console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`);
1597
+ else {
1598
+ const reasons = [];
1599
+ if (report.failingViolations > 0) reasons.push(`${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total)`);
1600
+ if (report.pagesFailedToLoad > 0) reasons.push(`${report.pagesFailedToLoad} page(s) failed to load`);
1601
+ console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${reasons.join("; ")}.`);
1602
+ }
1576
1603
  }
1577
1604
  //#endregion
1578
1605
  export { isPlausibleLanguageTag as a, validateProject as c, isIgnored as i, generateManifest as l, runAudit as n, normalizeA11y as o, resolvePackageRoot as r, reportValidationIssues as s, AUDIT_ENV_FLAG as t, readCourseConfig as u };
1579
1606
 
1580
- //# sourceMappingURL=audit-BA5o0ick.js.map
1607
+ //# sourceMappingURL=audit-BNrvFHq_.js.map