tessera-learn 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AGENTS.md +54 -4
  2. package/README.md +4 -4
  3. package/dist/{audit-CzKAXy3Y.js → audit-BA5o0ick.js} +19 -6
  4. package/dist/audit-BA5o0ick.js.map +1 -0
  5. package/dist/{build-commands-D101M_qb.js → build-commands-C0OnV-Vg.js} +6 -6
  6. package/dist/build-commands-C0OnV-Vg.js.map +1 -0
  7. package/dist/{inline-config-DYHT51G8.js → inline-config-CroQ-_2Y.js} +8 -6
  8. package/dist/inline-config-CroQ-_2Y.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +6 -2
  10. package/dist/plugin/cli.d.ts.map +1 -1
  11. package/dist/plugin/cli.js +242 -26
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +2 -2
  16. package/dist/{plugin-y35ym9A3.js → plugin-W_rk3Pit.js} +4 -17
  17. package/dist/plugin-W_rk3Pit.js.map +1 -0
  18. package/package.json +10 -1
  19. package/src/plugin/a11y/audit.ts +9 -4
  20. package/src/plugin/a11y-cli.ts +6 -2
  21. package/src/plugin/build-commands.ts +10 -4
  22. package/src/plugin/cli.ts +63 -20
  23. package/src/plugin/course-root.ts +98 -0
  24. package/src/plugin/duplicate-cli.ts +74 -0
  25. package/src/plugin/inline-config.ts +13 -2
  26. package/src/plugin/new-cli.ts +51 -0
  27. package/src/plugin/project-name.ts +29 -0
  28. package/src/plugin/template-copy.ts +43 -0
  29. package/src/plugin/validate-cli.ts +1 -1
  30. package/templates/course/course.config.js +11 -0
  31. package/templates/course/layout.svelte +116 -0
  32. package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
  33. package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
  34. package/templates/course/pages/01-getting-started/_meta.js +1 -0
  35. package/templates/course/styles/custom.css +5 -0
  36. package/dist/audit-CzKAXy3Y.js.map +0 -1
  37. package/dist/build-commands-D101M_qb.js.map +0 -1
  38. package/dist/inline-config-DYHT51G8.js.map +0 -1
  39. package/dist/plugin-y35ym9A3.js.map +0 -1
@@ -125,6 +125,7 @@ async function loadDeps(): Promise<
125
125
  */
126
126
  export async function runAudit(
127
127
  projectRoot: string,
128
+ workspaceRoot: string,
128
129
  options: AuditOptions = {},
129
130
  ): Promise<number> {
130
131
  const threshold: ImpactLevel = options.threshold ?? 'serious';
@@ -153,10 +154,14 @@ export async function runAudit(
153
154
  const { resolveTesseraConfig } = await import('../inline-config.js');
154
155
  // Carries tesseraPlugin() + the Svelte compiler; without it the plugin-less
155
156
  // build would silently produce a broken bundle (there is no vite.config.js).
156
- const auditBaseConfig = await resolveTesseraConfig(projectRoot, {
157
- command: 'build',
158
- mode: 'production',
159
- });
157
+ const auditBaseConfig = await resolveTesseraConfig(
158
+ projectRoot,
159
+ workspaceRoot,
160
+ {
161
+ command: 'build',
162
+ mode: 'production',
163
+ },
164
+ );
160
165
 
161
166
  // A throwaway web build, kept out of dist/ so a real LMS export is untouched.
162
167
  const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');
@@ -38,11 +38,15 @@ export function parseA11yArgs(argv: string[]): ParsedA11yArgs {
38
38
  return { ok: true, args };
39
39
  }
40
40
 
41
- export async function runA11y(argv: string[]): Promise<number> {
41
+ export async function runA11y(
42
+ projectRoot: string,
43
+ workspaceRoot: string,
44
+ argv: string[],
45
+ ): Promise<number> {
42
46
  const parsed = parseA11yArgs(argv);
43
47
  if (!parsed.ok) {
44
48
  console.error(`[tessera a11y] ${parsed.error}`);
45
49
  return 1;
46
50
  }
47
- return runAudit(process.cwd(), parsed.args);
51
+ return runAudit(projectRoot, workspaceRoot, parsed.args);
48
52
  }
@@ -1,8 +1,11 @@
1
1
  import { resolveTesseraConfig } from './inline-config.js';
2
2
 
3
- export async function runDev(projectRoot: string): Promise<number> {
3
+ export async function runDev(
4
+ projectRoot: string,
5
+ workspaceRoot: string,
6
+ ): Promise<number> {
4
7
  const vite = await import('vite');
5
- const config = await resolveTesseraConfig(projectRoot, {
8
+ const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
6
9
  command: 'serve',
7
10
  mode: 'development',
8
11
  });
@@ -14,9 +17,12 @@ export async function runDev(projectRoot: string): Promise<number> {
14
17
  return new Promise<number>(() => {});
15
18
  }
16
19
 
17
- export async function runBuild(projectRoot: string): Promise<number> {
20
+ export async function runBuild(
21
+ projectRoot: string,
22
+ workspaceRoot: string,
23
+ ): Promise<number> {
18
24
  const vite = await import('vite');
19
- const config = await resolveTesseraConfig(projectRoot, {
25
+ const config = await resolveTesseraConfig(projectRoot, workspaceRoot, {
20
26
  command: 'build',
21
27
  mode: 'production',
22
28
  });
package/src/plugin/cli.ts CHANGED
@@ -1,44 +1,87 @@
1
1
  #!/usr/bin/env node
2
2
  import { runValidate } from './validate-cli.js';
3
3
  import { runA11y } from './a11y-cli.js';
4
+ import { runNew } from './new-cli.js';
5
+ import { runDuplicate } from './duplicate-cli.js';
6
+ import { resolveCourse } from './course-root.js';
4
7
 
5
- const USAGE = `Usage: tessera <command> [options]
8
+ const USAGE = `Usage: tessera <command> [course] [options]
6
9
 
7
10
  Commands:
8
- dev Start the Vite dev server
9
- export Build and package the course for its LMS standard
10
- validate Fast static structure checks
11
- a11y [options] Runtime accessibility audit (builds + drives Playwright)
12
- check [options] Run validate, then a11y
11
+ new <name> Scaffold a new course into courses/<name>
12
+ duplicate <source> <new> Copy courses/<source> to courses/<new>
13
+ dev [course] Start the Vite dev server
14
+ export [course] Build and package the course for its LMS standard
15
+ validate [course] Fast static structure checks
16
+ a11y [course] Runtime accessibility audit (builds + drives Playwright)
17
+ check [course] Run validate, then a11y
18
+
19
+ Run a command from inside a course folder, or name the course explicitly.
13
20
 
14
21
  a11y/check options:
15
22
  --threshold <minor|moderate|serious|critical> Failing impact (default: serious)
16
23
  --build Force a fresh build first`;
17
24
 
18
- export async function main(argv: string[]): Promise<number> {
25
+ // The course is a leading positional: `tessera <cmd> [course] [flags]`. Only the
26
+ // first token can be the course, and only when it isn't a flag — otherwise a flag
27
+ // value (e.g. the `serious` in `--threshold serious`) would be misread as a name.
28
+ export function splitCourseArg(rest: string[]): {
29
+ course?: string;
30
+ flags: string[];
31
+ } {
32
+ if (rest.length > 0 && !rest[0].startsWith('-')) {
33
+ return { course: rest[0], flags: rest.slice(1) };
34
+ }
35
+ return { course: undefined, flags: rest };
36
+ }
37
+
38
+ export async function main(
39
+ argv: string[],
40
+ cwd: string = process.cwd(),
41
+ ): Promise<number> {
19
42
  const [sub, ...rest] = argv;
43
+
44
+ if (sub === 'new') return runNew(rest[0], cwd);
45
+ if (sub === 'duplicate') return runDuplicate(rest[0], rest[1], cwd);
46
+
20
47
  switch (sub) {
21
- case 'dev': {
22
- const { runDev } = await import('./build-commands.js');
23
- return runDev(process.cwd());
24
- }
25
- case 'export': {
26
- const { runBuild } = await import('./build-commands.js');
27
- return runBuild(process.cwd());
28
- }
48
+ case 'dev':
49
+ case 'export':
29
50
  case 'validate':
30
- return runValidate(process.cwd());
31
51
  case 'a11y':
32
52
  case 'check': {
33
53
  if (rest.includes('--help') || rest.includes('-h')) {
34
54
  console.log(USAGE);
35
55
  return 0;
36
56
  }
37
- if (sub === 'check') {
38
- const validateCode = runValidate(process.cwd());
39
- if (validateCode !== 0) return validateCode;
57
+ const { course, flags } = splitCourseArg(rest);
58
+ const resolved = resolveCourse(cwd, course);
59
+ if (!resolved.ok) {
60
+ console.error(`[tessera] ${resolved.error}`);
61
+ return 1;
40
62
  }
41
- return runA11y(rest);
63
+ const { courseRoot, workspaceRoot } = resolved;
64
+
65
+ switch (sub) {
66
+ case 'dev': {
67
+ const { runDev } = await import('./build-commands.js');
68
+ return runDev(courseRoot, workspaceRoot);
69
+ }
70
+ case 'export': {
71
+ const { runBuild } = await import('./build-commands.js');
72
+ return runBuild(courseRoot, workspaceRoot);
73
+ }
74
+ case 'validate':
75
+ return runValidate(courseRoot);
76
+ case 'check': {
77
+ const validateCode = runValidate(courseRoot);
78
+ if (validateCode !== 0) return validateCode;
79
+ return runA11y(courseRoot, workspaceRoot, flags);
80
+ }
81
+ case 'a11y':
82
+ return runA11y(courseRoot, workspaceRoot, flags);
83
+ }
84
+ return 0;
42
85
  }
43
86
  case '--help':
44
87
  case '-h':
@@ -0,0 +1,98 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { validateProjectName } from './project-name.js';
4
+
5
+ export interface ResolvedCourse {
6
+ ok: true;
7
+ courseRoot: string;
8
+ workspaceRoot: string;
9
+ }
10
+
11
+ export type ResolveResult = ResolvedCourse | { ok: false; error: string };
12
+
13
+ function isDir(path: string): boolean {
14
+ try {
15
+ return statSync(path).isDirectory();
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function isCourse(dir: string): boolean {
22
+ return existsSync(join(dir, 'course.config.js'));
23
+ }
24
+
25
+ // A workspace is the nearest ancestor holding a courses/ directory. The walk
26
+ // includes the starting dir, so the workspace root resolves to itself.
27
+ export function findWorkspaceRoot(cwd: string): string | null {
28
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
29
+ if (isDir(join(dir, 'courses'))) return dir;
30
+ const parent = dirname(dir);
31
+ if (parent === dir) return null;
32
+ }
33
+ }
34
+
35
+ export function listCourses(workspaceRoot: string): string[] {
36
+ const coursesDir = join(workspaceRoot, 'courses');
37
+ try {
38
+ return readdirSync(coursesDir, { withFileTypes: true })
39
+ .filter((e) => e.isDirectory() && isCourse(join(coursesDir, e.name)))
40
+ .map((e) => e.name)
41
+ .sort();
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ const NOT_A_WORKSPACE =
48
+ 'Not inside a Tessera workspace — no `courses/` directory was found at or above the current directory.';
49
+
50
+ function listHint(workspaceRoot: string): string {
51
+ const courses = listCourses(workspaceRoot);
52
+ if (courses.length === 0) {
53
+ return '\nNo courses found. Create one with `tessera new <name>`.';
54
+ }
55
+ return (
56
+ `\nAvailable courses:\n${courses.map((c) => ` ${c}`).join('\n')}` +
57
+ '\nName one (`tessera <command> <course>`) or cd into its folder.'
58
+ );
59
+ }
60
+
61
+ // A name argument always wins; otherwise the cwd must itself be a course. There
62
+ // is deliberately no "single course → use it implicitly" rule, so a bare command
63
+ // never changes meaning when a second course is added.
64
+ export function resolveCourse(cwd: string, name?: string): ResolveResult {
65
+ const here = resolve(cwd);
66
+
67
+ if (name) {
68
+ const nameError = validateProjectName(name, 'the name');
69
+ if (nameError) {
70
+ return {
71
+ ok: false,
72
+ error: `Invalid course name "${name}" — ${nameError}.`,
73
+ };
74
+ }
75
+ const workspaceRoot = findWorkspaceRoot(here);
76
+ if (!workspaceRoot) return { ok: false, error: NOT_A_WORKSPACE };
77
+ const courseRoot = join(workspaceRoot, 'courses', name);
78
+ if (!isCourse(courseRoot)) {
79
+ return {
80
+ ok: false,
81
+ error: `Course "${name}" not found in courses/.${listHint(workspaceRoot)}`,
82
+ };
83
+ }
84
+ return { ok: true, courseRoot, workspaceRoot };
85
+ }
86
+
87
+ if (isCourse(here)) {
88
+ const workspaceRoot = findWorkspaceRoot(here) ?? dirname(dirname(here));
89
+ return { ok: true, courseRoot: here, workspaceRoot };
90
+ }
91
+
92
+ const workspaceRoot = findWorkspaceRoot(here);
93
+ if (!workspaceRoot) return { ok: false, error: NOT_A_WORKSPACE };
94
+ return {
95
+ ok: false,
96
+ error: `No course specified.${listHint(workspaceRoot)}`,
97
+ };
98
+ }
@@ -0,0 +1,74 @@
1
+ import { cpSync, existsSync } from 'node:fs';
2
+ import { basename, join, relative } from 'node:path';
3
+ import { resolveCourse } from './course-root.js';
4
+ import { validateProjectName } from './project-name.js';
5
+
6
+ const HELP =
7
+ 'Usage: tessera duplicate <source> <new>\n\n' +
8
+ 'Copy courses/<source>/ to courses/<new>/ within the current workspace.';
9
+
10
+ // Generated/build artifacts that should never travel with a verbatim copy. The
11
+ // a11y throwaway build and Vite's cache live under node_modules, so they're
12
+ // already pruned by the node_modules skip; the rest are belt-and-suspenders.
13
+ const SKIP = new Set(['node_modules', 'dist', 'a11y-report.json', '.vite']);
14
+
15
+ function skip(srcPath: string): boolean {
16
+ const name = basename(srcPath);
17
+ return SKIP.has(name) || name.startsWith('.tessera');
18
+ }
19
+
20
+ // `tessera duplicate <source> <new>` — copy an existing course verbatim within
21
+ // the current workspace. Unlike `new`, there is no template stamping: the JS
22
+ // config (including its title) is copied untouched.
23
+ export function runDuplicate(
24
+ source: string | undefined,
25
+ target: string | undefined,
26
+ cwd: string,
27
+ ): number {
28
+ if (
29
+ source === '--help' ||
30
+ source === '-h' ||
31
+ target === '--help' ||
32
+ target === '-h'
33
+ ) {
34
+ console.log(HELP);
35
+ return 0;
36
+ }
37
+ if (!source || !target) {
38
+ console.error('Usage: tessera duplicate <source> <new>');
39
+ return 1;
40
+ }
41
+
42
+ const nameError = validateProjectName(target, 'Course name');
43
+ if (nameError) {
44
+ console.error(`[tessera duplicate] ${nameError}`);
45
+ return 1;
46
+ }
47
+
48
+ const resolved = resolveCourse(cwd, source);
49
+ if (!resolved.ok) {
50
+ console.error(`[tessera duplicate] ${resolved.error}`);
51
+ return 1;
52
+ }
53
+ const { courseRoot: srcDir, workspaceRoot } = resolved;
54
+
55
+ const destDir = join(workspaceRoot, 'courses', target);
56
+ if (existsSync(destDir)) {
57
+ console.error(`[tessera duplicate] Course "${target}" already exists.`);
58
+ return 1;
59
+ }
60
+
61
+ cpSync(srcDir, destDir, {
62
+ recursive: true,
63
+ filter: (src) => src === srcDir || !skip(src),
64
+ });
65
+
66
+ const rel = relative(workspaceRoot, destDir);
67
+ const srcRel = relative(workspaceRoot, srcDir);
68
+ console.log(
69
+ `\nCreated ${rel} (duplicated from ${srcRel}).\n\n` +
70
+ `Remember to update the title in ${rel}/course.config.js.\n\n` +
71
+ `Next steps:\n pnpm tessera dev ${target}\n`,
72
+ );
73
+ return 0;
74
+ }
@@ -7,11 +7,21 @@ import { tesseraPlugin } from './index.js';
7
7
  // Base Vite config for every Tessera command (dev, export, a11y build).
8
8
  // configFile:false disables Vite's own discovery — there is no vite.config.js —
9
9
  // and tesseraPlugin() supplies the Svelte compiler, so this is the full plugin set.
10
- export function buildInlineConfig(projectRoot: string): InlineConfig {
10
+ //
11
+ // $shared points at the workspace-level design system, which lives outside the
12
+ // per-course Vite root, so it is wired here (where workspaceRoot is known) rather
13
+ // than next to $assets in the plugin. server.fs.allow must list workspaceRoot or
14
+ // the dev server's fs.strict gate refuses to serve $shared files.
15
+ export function buildInlineConfig(
16
+ projectRoot: string,
17
+ workspaceRoot: string,
18
+ ): InlineConfig {
11
19
  return {
12
20
  root: projectRoot,
13
21
  configFile: false,
14
22
  plugins: [tesseraPlugin()],
23
+ resolve: { alias: { $shared: resolve(workspaceRoot, 'shared') } },
24
+ server: { fs: { allow: [workspaceRoot] } },
15
25
  };
16
26
  }
17
27
 
@@ -34,10 +44,11 @@ export async function loadUserConfig(
34
44
 
35
45
  export async function resolveTesseraConfig(
36
46
  projectRoot: string,
47
+ workspaceRoot: string,
37
48
  env: ConfigEnv,
38
49
  ): Promise<InlineConfig> {
39
50
  const vite = await import('vite');
40
- const base = buildInlineConfig(projectRoot);
51
+ const base = buildInlineConfig(projectRoot, workspaceRoot);
41
52
  const user = await loadUserConfig(projectRoot, env);
42
53
  return user ? vite.mergeConfig(base, user) : base;
43
54
  }
@@ -0,0 +1,51 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { findWorkspaceRoot } from './course-root.js';
4
+ import { validateProjectName, toTitleCase } from './project-name.js';
5
+ import { copyTemplate } from './template-copy.js';
6
+ import { resolvePackageRoot } from './package-root.js';
7
+
8
+ // `tessera new <name>` — stamp a new course into courses/<name> inside the
9
+ // current workspace. No install step: the workspace already owns the deps.
10
+ export function runNew(name: string | undefined, cwd: string): number {
11
+ if (name === '--help' || name === '-h') {
12
+ console.log(
13
+ 'Usage: tessera new <name>\n\n' +
14
+ 'Scaffold a new course into courses/<name> inside the current workspace.',
15
+ );
16
+ return 0;
17
+ }
18
+ if (!name) {
19
+ console.error('Usage: tessera new <name>');
20
+ return 1;
21
+ }
22
+
23
+ const nameError = validateProjectName(name, 'Course name');
24
+ if (nameError) {
25
+ console.error(`[tessera new] ${nameError}`);
26
+ return 1;
27
+ }
28
+
29
+ const workspaceRoot = findWorkspaceRoot(resolve(cwd));
30
+ if (!workspaceRoot) {
31
+ console.error(
32
+ '[tessera new] Not inside a Tessera workspace — run this from a workspace (a directory with a `courses/` folder).',
33
+ );
34
+ return 1;
35
+ }
36
+
37
+ const courseDir = join(workspaceRoot, 'courses', name);
38
+ if (existsSync(courseDir)) {
39
+ console.error(`[tessera new] Course "${name}" already exists.`);
40
+ return 1;
41
+ }
42
+
43
+ const templateDir = join(resolvePackageRoot(), 'templates', 'course');
44
+ copyTemplate(templateDir, courseDir, {
45
+ PROJECT_TITLE: toTitleCase(name),
46
+ });
47
+
48
+ const rel = relative(workspaceRoot, courseDir);
49
+ console.log(`\nCreated ${rel}.\n\nNext steps:\n pnpm tessera dev ${name}\n`);
50
+ return 0;
51
+ }
@@ -0,0 +1,29 @@
1
+ // Pure, dependency-free helpers shared by the `tessera new` subcommand and the
2
+ // `create-tessera` scaffolder. Kept import-free so create-tessera can bundle it
3
+ // at build time without dragging in the rest of the plugin (vite, svelte, …).
4
+
5
+ // npm package name rules: 1-214 chars, lowercase, must start with [a-z0-9],
6
+ // allowed chars [a-z0-9._-], no leading dot or underscore.
7
+ export function validateProjectName(
8
+ name: string,
9
+ label = 'Project name',
10
+ ): string | null {
11
+ if (!name) return `${label} is required`;
12
+ if (name.length > 214) return `${label} must be 214 characters or fewer`;
13
+ if (name !== name.toLowerCase()) return `${label} must be lowercase`;
14
+ if (!/^[a-z0-9]/.test(name)) {
15
+ return `${label} must start with a letter or digit`;
16
+ }
17
+ if (!/^[a-z0-9._-]+$/.test(name)) {
18
+ return `${label} may only contain lowercase letters, digits, "-", "_", and "."`;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ export function toTitleCase(slug: string): string {
24
+ return slug
25
+ .split(/[-_.\s]+/)
26
+ .filter(Boolean)
27
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
28
+ .join(' ');
29
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ copyFileSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ // npm's tarball packing strips/renames leading-dot files, so templates store
11
+ // them prefixed and we restore the dot on copy. (create-vite convention.)
12
+ const RENAME: Record<string, string> = {
13
+ _gitignore: '.gitignore',
14
+ _gitkeep: '.gitkeep',
15
+ };
16
+
17
+ // Text files get token substitution; everything else is copied byte-for-byte.
18
+ const TEXT = /\.(svelte|js|ts|json|css|md|html)$/;
19
+
20
+ function applyTokens(s: string, tokens: Record<string, string>): string {
21
+ return s.replace(/__([A-Z_]+)__/g, (m, key) =>
22
+ key in tokens ? tokens[key] : m,
23
+ );
24
+ }
25
+
26
+ export function copyTemplate(
27
+ srcDir: string,
28
+ destDir: string,
29
+ tokens: Record<string, string>,
30
+ ): void {
31
+ mkdirSync(destDir, { recursive: true });
32
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
33
+ const src = join(srcDir, entry.name);
34
+ const dest = join(destDir, RENAME[entry.name] ?? entry.name);
35
+ if (entry.isDirectory()) {
36
+ copyTemplate(src, dest, tokens);
37
+ } else if (TEXT.test(entry.name)) {
38
+ writeFileSync(dest, applyTokens(readFileSync(src, 'utf-8'), tokens));
39
+ } else {
40
+ copyFileSync(src, dest);
41
+ }
42
+ }
43
+ }
@@ -24,7 +24,7 @@ export function runValidate(projectRoot: string): number {
24
24
  );
25
25
  }
26
26
  console.log(
27
- '\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: tessera a11y\x1b[0m',
27
+ '\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: pnpm exec tessera a11y\x1b[0m',
28
28
  );
29
29
  return 0;
30
30
  }
@@ -0,0 +1,11 @@
1
+ export default {
2
+ title: '__PROJECT_TITLE__',
3
+ language: 'en',
4
+ navigation: { mode: 'free' },
5
+ completion: { mode: 'percentage', percentageThreshold: 100 },
6
+ scoring: { passingScore: 70 },
7
+ branding: {
8
+ primaryColor: '#0066cc',
9
+ },
10
+ export: { standard: 'web' },
11
+ };
@@ -0,0 +1,116 @@
1
+ <script>
2
+ import '$shared/tokens.css';
3
+ import { useNavigation, useProgress } from 'tessera-learn';
4
+
5
+ let { page } = $props();
6
+ const nav = useNavigation();
7
+ const progress = useProgress();
8
+ </script>
9
+
10
+ <div class="course">
11
+ <header>
12
+ <h1>__PROJECT_TITLE__</h1>
13
+ <span class="progress"
14
+ >{progress.visitedPages.size} / {nav.pages.length}</span
15
+ >
16
+ </header>
17
+
18
+ <main>
19
+ {@render page()}
20
+ </main>
21
+
22
+ <footer>
23
+ <button
24
+ class="nav-btn"
25
+ disabled={!nav.canGoPrev}
26
+ onclick={() => nav.prev()}
27
+ >
28
+ ← Previous
29
+ </button>
30
+ <button
31
+ class="nav-btn nav-btn--primary"
32
+ disabled={!nav.canGoNext}
33
+ onclick={() => nav.next()}
34
+ >
35
+ Next →
36
+ </button>
37
+ </footer>
38
+ </div>
39
+
40
+ <style>
41
+ .course {
42
+ display: flex;
43
+ flex-direction: column;
44
+ min-height: 100vh;
45
+ }
46
+
47
+ header {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ gap: var(--tessera-spacing-md);
52
+ padding: var(--tessera-spacing-md) var(--tessera-spacing-xl);
53
+ border-bottom: 1px solid var(--tessera-border);
54
+ }
55
+
56
+ header h1 {
57
+ margin: 0;
58
+ font-size: 1.125rem;
59
+ }
60
+
61
+ .progress {
62
+ flex: none;
63
+ padding: 0.15rem 0.6rem;
64
+ border-radius: 999px;
65
+ background: var(--tessera-bg-secondary);
66
+ color: var(--tessera-text-light);
67
+ font-size: 0.875rem;
68
+ }
69
+
70
+ main {
71
+ flex: 1;
72
+ width: 100%;
73
+ max-width: var(--tessera-content-max-width);
74
+ margin: 0 auto;
75
+ padding: var(--tessera-spacing-xl);
76
+ }
77
+
78
+ footer {
79
+ display: flex;
80
+ justify-content: space-between;
81
+ gap: var(--tessera-spacing-md);
82
+ padding: var(--tessera-spacing-md) var(--tessera-spacing-xl);
83
+ border-top: 1px solid var(--tessera-border);
84
+ }
85
+
86
+ .nav-btn {
87
+ padding: 0.5rem 1rem;
88
+ border: 1px solid var(--tessera-border);
89
+ border-radius: 6px;
90
+ background: var(--tessera-bg);
91
+ color: var(--tessera-text);
92
+ cursor: pointer;
93
+ transition:
94
+ background var(--tessera-transition-fast),
95
+ border-color var(--tessera-transition-fast);
96
+ }
97
+
98
+ .nav-btn:hover:not(:disabled) {
99
+ background: var(--tessera-bg-secondary);
100
+ }
101
+
102
+ .nav-btn--primary {
103
+ border-color: transparent;
104
+ background: var(--tessera-primary);
105
+ color: #fff;
106
+ }
107
+
108
+ .nav-btn--primary:hover:not(:disabled) {
109
+ background: var(--tessera-primary-dark);
110
+ }
111
+
112
+ .nav-btn:disabled {
113
+ opacity: 0.5;
114
+ cursor: not-allowed;
115
+ }
116
+ </style>
@@ -0,0 +1 @@
1
+ export default { title: 'Welcome', pages: ['welcome'] };
@@ -0,0 +1,19 @@
1
+ <script module>
2
+ export const pageConfig = { title: 'Welcome' };
3
+ </script>
4
+
5
+ <script>
6
+ import Button from '$shared/Button.svelte';
7
+ </script>
8
+
9
+ <h1>Welcome to __PROJECT_TITLE__</h1>
10
+
11
+ <p>This is a basic demo page of your Tessera course.</p>
12
+
13
+ <p>
14
+ Point your agent to <code>AGENTS.md</code> at the workspace root for the
15
+ authoring guide. The button below is imported from the workspace design system
16
+ via <code>$shared</code>.
17
+ </p>
18
+
19
+ <Button>A shared button</Button>
@@ -0,0 +1 @@
1
+ export default { title: 'Getting Started' };