diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index 1a26365..c631419 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -673,6 +673,50 @@ def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None: return CheckResult(CheckStatus.OK, "frontend stack", "Tailwind + shadcn-svelte + TS detected") +def _check_mobile_first(ctx: RepoCtx) -> CheckResult | None: + """Fullstack mobile-first guarantee (issue #278): PWA manifest + mobile + Playwright + axe-core accessibility. + + Returns ``None`` if ``frontend/package.json`` does not exist (the missing + package.json WARN is surfaced separately by ``_check_type_specific_structure``). + Returns ``None`` for non-fullstack types — the caller only invokes this for + FULLSTACK. Otherwise returns OK when all markers are present, or WARN with + the missing marker list (never FAIL — issue #275 all-WARN contract). + + Markers (``MOBILE_FIRST_MARKERS``): + * ``frontend/static/manifest.webmanifest`` exists + * ``frontend/src/app.html`` contains both "viewport" and "manifest" + * ``frontend/tests/e2e/mobile.spec.ts`` exists + * ``frontend/tests/e2e/accessibility.spec.ts`` exists + * ``@axe-core/playwright`` in ``frontend/package.json`` devDependencies + """ + pkg_path = ctx.root / "frontend" / "package.json" + if not pkg_path.exists(): + return None + missing: list[str] = [] + for rel in project_contract.MOBILE_FIRST_MARKERS["fullstack_files"]: + if not (ctx.root / rel).exists(): + missing.append(rel) + app_html = read_text("frontend/src/app.html", ctx) or "" + for marker in project_contract.MOBILE_FIRST_MARKERS["fullstack_app_html_markers"]: + if marker not in app_html: + missing.append(f"frontend/src/app.html ({marker})") + try: + pkg = json.loads(pkg_path.read_text(encoding="utf-8-sig")) + except (json.JSONDecodeError, OSError): + pkg = {} + dev_deps = pkg.get("devDependencies", {}) if isinstance(pkg, dict) else {} + if "@axe-core/playwright" not in dev_deps: + missing.append("@axe-core/playwright in package.json devDependencies") + if missing: + return CheckResult( + CheckStatus.WARN, + "mobile-first", + "missing " + ", ".join(missing), + ) + return CheckResult(CheckStatus.OK, "mobile-first", "PWA + mobile Playwright + a11y detected") + + def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]: """Type-specific extra checks beyond the expected dirs list.""" results: list[CheckResult] = [] @@ -688,6 +732,9 @@ def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[Che frontend_stack = _check_frontend_stack(ctx) if frontend_stack is not None: results.append(frontend_stack) + mobile = _check_mobile_first(ctx) + if mobile is not None: + results.append(mobile) if ptype == ProjectType.CLI: results.append(_check_cli_package(ctx)) flat = _check_flat_layout(ptype, ctx) diff --git a/.opencode/scripts/project_contract.py b/.opencode/scripts/project_contract.py index c4a3c0b..7de965f 100644 --- a/.opencode/scripts/project_contract.py +++ b/.opencode/scripts/project_contract.py @@ -78,3 +78,18 @@ FRONTEND_STACK_MARKERS: dict[str, list[str]] = { "fullstack_package_deps": ["tailwindcss", "bits-ui"], "fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"], } + +# Fullstack mobile-first markers (project-status _check_mobile_first, issue #278). +# Distinct from FRONTEND_STACK_MARKERS so the stack check stays focused on the +# Tailwind/shadcn/TS trio. Keys: +# fullstack_files — paths (relative to repo root) that must exist +# for PWA + mobile Playwright + a11y to be present. +# fullstack_app_html_markers — substrings that must appear in app.html . +MOBILE_FIRST_MARKERS: dict[str, list[str]] = { + "fullstack_files": [ + "frontend/static/manifest.webmanifest", + "frontend/tests/e2e/mobile.spec.ts", + "frontend/tests/e2e/accessibility.spec.ts", + ], + "fullstack_app_html_markers": ["viewport", "manifest"], +} diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json index 9e6fb76..cb9f57d 100644 --- a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json @@ -30,6 +30,7 @@ "@sveltejs/adapter-node": "^5.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@axe-core/playwright": "^4.10.0", "@biomejs/biome": "^1.9.0", "knip": "^5.30.0", "svelte": "^5.0.0", diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js index d2d3bfb..4f58a92 100644 --- a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js @@ -12,6 +12,7 @@ export default defineConfig({ }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'mobile-chrome', use: { ...devices['iPhone SE'], isMobile: true, hasTouch: true } }, ], webServer: { command: 'npm run preview', diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html index 277886b..fcba6f1 100644 --- a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html @@ -2,8 +2,11 @@ - + + + + %sveltekit.head% diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/static/icon.svg b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/static/icon.svg new file mode 100644 index 0000000..5bf6fdc --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/static/icon.svg @@ -0,0 +1,4 @@ + + + {{ cookiecutter.project_name | first | upper }} + \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/static/manifest.webmanifest b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/static/manifest.webmanifest new file mode 100644 index 0000000..ccb5351 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/static/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "{{ cookiecutter.project_name }}", + "short_name": "{{ cookiecutter.project_name }}", + "description": "{{ cookiecutter.description }}", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#0f172a", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/tests/e2e/accessibility.spec.ts b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/tests/e2e/accessibility.spec.ts new file mode 100644 index 0000000..a398fba --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/tests/e2e/accessibility.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +test('dashboard has no a11y violations', async ({ page }) => { + await page.goto('/'); + const results = await new AxeBuilder({ page }) + .disableRules(['color-contrast']) + .analyze(); + expect(results.violations).toEqual([]); +}); \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/tests/e2e/mobile.spec.ts b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/tests/e2e/mobile.spec.ts new file mode 100644 index 0000000..5582656 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/tests/e2e/mobile.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from '@playwright/test'; + +test('sidebar collapses on mobile viewport', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('[data-sidebar="sidebar"]')).not.toBeVisible(); + await expect(page.locator('[data-sidebar="trigger"]')).toBeVisible(); +}); \ No newline at end of file diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index 7ca4cf9..1626aac 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -258,6 +258,11 @@ def test_fullstack_structure(render): assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists() assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists() assert (render / "frontend/tests/e2e/app.spec.ts").exists() + # issue #278: mobile-first — PWA manifest + SVG icon + mobile + a11y specs + assert (render / "frontend/static/manifest.webmanifest").exists() + assert (render / "frontend/static/icon.svg").exists() + assert (render / "frontend/tests/e2e/mobile.spec.ts").exists() + assert (render / "frontend/tests/e2e/accessibility.spec.ts").exists() # removed: counter.svelte.js, Header.svelte, jsconfig.json, +page.js # root CI runs both assert (render / ".github/workflows/ci.yml").exists() diff --git a/tests/test_project_contract.py b/tests/test_project_contract.py index 18867f3..7ae4916 100644 --- a/tests/test_project_contract.py +++ b/tests/test_project_contract.py @@ -102,6 +102,28 @@ def test_frontend_stack_markers_keys(): ] +def test_mobile_first_markers_keys(): + """MOBILE_FIRST_MARKERS (issue #278) has the expected keys. + + Distinct from FRONTEND_STACK_MARKERS so the stack check stays focused on + the Tailwind/shadcn/TS trio while mobile-first (PWA + mobile Playwright + + a11y) is a separate dict. + """ + assert set(pc.MOBILE_FIRST_MARKERS.keys()) == { + "fullstack_files", + "fullstack_app_html_markers", + } + assert pc.MOBILE_FIRST_MARKERS["fullstack_files"] == [ + "frontend/static/manifest.webmanifest", + "frontend/tests/e2e/mobile.spec.ts", + "frontend/tests/e2e/accessibility.spec.ts", + ] + assert pc.MOBILE_FIRST_MARKERS["fullstack_app_html_markers"] == [ + "viewport", + "manifest", + ] + + # ── re-export sanity (backward compat) ───────────────────────────────────── diff --git a/tests/test_project_status.py b/tests/test_project_status.py index 1163e55..55fa6c8 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -241,12 +241,28 @@ def _make_fullstack_repo(tmp_path: Path) -> None: # Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS # (issue #266): package.json deps + components.json + tsconfig.json are the # 4 frontend stack markers checked by ``_check_frontend_stack``. + # Issue #278: mobile-first markers (manifest, mobile.spec.ts, a11y.spec.ts, + # app.html viewport+manifest, @axe-core/playwright) are also present so the + # default fixture mirrors a freshly generated fullstack cookiecutter repo. (tmp_path / "frontend" / "package.json").write_text( '{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", ' - '"bits-ui": "^1.0.0"}}\n' + '"bits-ui": "^1.0.0"}, "devDependencies": {"@axe-core/playwright": "^4.10.0"}}\n' ) (tmp_path / "frontend" / "tsconfig.json").write_text('{"compilerOptions": {}}\n') (tmp_path / "frontend" / "components.json").write_text("{}\n") + (tmp_path / "frontend" / "static").mkdir(parents=True, exist_ok=True) + (tmp_path / "frontend" / "static" / "manifest.webmanifest").write_text("{}\n") + (tmp_path / "frontend" / "static" / "icon.svg").write_text("\n") + (tmp_path / "frontend" / "src").mkdir(parents=True, exist_ok=True) + (tmp_path / "frontend" / "src" / "app.html").write_text( + "" + '' + '' + "\n" + ) + (tmp_path / "frontend" / "tests" / "e2e").mkdir(parents=True, exist_ok=True) + (tmp_path / "frontend" / "tests" / "e2e" / "mobile.spec.ts").write_text("// mobile\n") + (tmp_path / "frontend" / "tests" / "e2e" / "accessibility.spec.ts").write_text("// a11y\n") # ── parse_remote_url ───────────────────────────────────────────────────────── @@ -2243,6 +2259,45 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx): assert "tsconfig.json" in frontend[0].detail +# ── issue #278: mobile-first guarantee (fullstack only) ───────────────────── + + +def test_fullstack_mobile_first_ok(tmp_path, ctx): + """All mobile-first markers present (manifest, mobile.spec.ts, a11y.spec.ts, + app.html viewport+manifest, @axe-core/playwright) → OK.""" + _make_fullstack_repo(tmp_path) + group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx) + mobile = [c for c in group.checks if c.name == "mobile-first"] + assert mobile, f"expected 'mobile-first' check, got: {group.checks}" + assert mobile[0].status == ps.CheckStatus.OK, ( + f"expected OK, got {mobile[0].status}: {mobile[0].detail}" + ) + + +def test_fullstack_mobile_first_warns_missing_manifest(tmp_path, ctx): + """Missing ``frontend/static/manifest.webmanifest`` → WARN (non-blocking).""" + _make_fullstack_repo(tmp_path) + (tmp_path / "frontend" / "static" / "manifest.webmanifest").unlink() + group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx) + mobile = [c for c in group.checks if c.name == "mobile-first"] + assert mobile and mobile[0].status == ps.CheckStatus.WARN, ( + f"expected WARN for missing manifest, got: {mobile}" + ) + assert "manifest.webmanifest" in mobile[0].detail + + +def test_fullstack_mobile_first_skips_backend(tmp_path, ctx): + """Backend project type → ``_check_mobile_first`` not invoked (skip). + + Mobile-first is a FULLSTACK-only guarantee; the structure group for + backend must not contain a "mobile-first" check. + """ + _make_backend_repo(tmp_path) + group = ps.check_structure(ps.ProjectType.BACKEND, ctx) + mobile = [c for c in group.checks if c.name == "mobile-first"] + assert not mobile, f"backend must skip mobile-first, got: {mobile}" + + # ── issue #274: db/models auto-detect from deps ──────────────────────────────