Compare commits

...

2 commits

Author SHA1 Message Date
Sergey
9459e47567
feat(spec): mobile-first silent enforcement in STACK_REQUIRED + CI e2e (#281)
Some checks failed
CI / bootstrap (push) Successful in 52s
CI / lint (push) Successful in 2m2s
CI / typecheck (push) Successful in 27s
CI / test (3.12) (push) Failing after 2m53s
CI / test (3.13) (push) Failing after 2m0s
CI / test (3.14) (push) Failing after 1m48s
CI / complexity (push) Successful in 23s
* feat(spec): mobile-first silent enforcement in STACK_REQUIRED + Template C

* feat(ci): frontend-e2e job with Playwright in fullstack cookiecutter

* docs(skills): mention mobile-first in audit, code-standards, project-status tool

* fix(ci): use npm install instead of npm ci + add e2e regression test

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 07:05:14 +03:00
Sergey
6bb115e706
feat(fullstack): mobile-first guarantee — cookiecutter + project-status (#280)
* feat(scripts): add MOBILE_FIRST_MARKERS + _check_mobile_first check

* feat(templates): fullstack mobile-first PWA manifest + mobile Playwright + axe

* test(scripts): cover mobile-first markers, check, cookiecutter structure

* fix(ci): reformat test_project_status.py string literals

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 06:46:20 +03:00
17 changed files with 235 additions and 10 deletions

View file

@ -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)

View file

@ -51,6 +51,7 @@ STACK_REQUIRED: dict[str, list[str]] = {
"tailwind",
"shadcn",
"typescript",
"mobile-first",
],
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
@ -78,3 +79,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 <head>.
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"],
}

View file

@ -96,6 +96,7 @@ REVIEW → MERGE).
- service-слой пропущен (routes → db/models без services/)
- файлы длиннее 200-300 строк (декомпозиция)
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление)
- mobile-first missing (fullstack): нет PWA manifest, нет Playwright mobile spec, нет axe a11y spec, нет viewport meta — `STACK_REQUIRED["fullstack"]` требует "mobile-first", но качественно проверь что mobile-first реален, а не просто слово в stack.md
Для каждой находки верни:
{category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}

View file

@ -104,7 +104,7 @@ src/<package>/
### Fullstack (кратко)
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот.
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот. + mobile-first (PWA + Playwright mobile + axe a11y) — silent enforcement через `STACK_REQUIRED["fullstack"]`.
### CLI (кратко)

View file

@ -42,7 +42,7 @@ truth для порядка и действий — `spec-status` tool. На в
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- **cli**: Typer (default) / click / argparse, hatchling build
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
@ -88,7 +88,7 @@ backend:
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
fullstack:
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default) / [2] add later
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default, mobile-first: PWA + axe + Playwright mobile — silent) / [2] add later
- DB: (same as backend)
- Auth: (same as backend)
@ -236,7 +236,7 @@ Spec complete. Issues: #N1, #N2, ...
Default stack для типа (хардкод, добавить всегда):
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
- cli: Typer (default) / click / argparse, hatchling build
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)

View file

@ -46,9 +46,21 @@ jobs:
- run: npm run lint
- run: npm test
frontend-e2e:
runs-on: ubuntu-latest
defaults: { run: { working-directory: frontend } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm install
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run test:e2e
build:
runs-on: ubuntu-latest
needs: [backend-lint, backend-typecheck, backend-test, frontend-test]
needs: [backend-lint, backend-typecheck, backend-test, frontend-test, frontend-e2e]
steps:
- uses: actions/checkout@v4
- run: echo "All checks passed"

View file

@ -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",

View file

@ -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',

View file

@ -2,8 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" rx="32" fill="#0f172a" />
<text x="96" y="128" font-family="system-ui, -apple-system, sans-serif" font-size="112" font-weight="700" fill="#f8fafc" text-anchor="middle">{{ cookiecutter.project_name | first | upper }}</text>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View file

@ -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"
}
]
}

View file

@ -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([]);
});

View file

@ -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();
});

View file

@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
export default tool({
description:
"Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 8 check groups: Структура, Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo=<path> to check an arbitrary repo instead of the current worktree.",
"Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 8 check groups: Структура (incl. mobile-first PWA + Playwright mobile + axe a11y for fullstack), Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo=<path> to check an arbitrary repo instead of the current worktree.",
args: {
check: tool.schema.boolean().optional().describe("Accepted for CLI compatibility — issue #275: exit code is always 0 (all checks WARN, non-blocking)"),
fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"),

View file

@ -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()
@ -397,6 +402,30 @@ def test_fullstack_uses_typescript(render):
assert "tw-animate" not in deps or "tw-animate-css" in deps
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_ci_has_frontend_e2e_job(render):
"""Root ci.yml has a ``frontend-e2e`` job that installs deps without a
lockfile (``npm install``, NOT ``npm ci``), wires Playwright, and runs
in the ``frontend/`` working directory.
Regression guard for PR#281 review critical #1: cookiecutter templates
ship no ``package-lock.json``, so ``npm ci`` fails in fresh projects.
"""
ci = (render / ".github/workflows/ci.yml").read_text()
assert "frontend-e2e" in ci, "ci.yml must define a frontend-e2e job"
assert "npm install" in ci, "ci.yml must use npm install (no lockfile in template)"
assert "npm ci" not in ci, (
"ci.yml must NOT use npm ci (cookiecutter template has no package-lock.json)"
)
assert "playwright install --with-deps" in ci, (
"ci.yml must install Playwright browsers with --with-deps"
)
assert "test:e2e" in ci, "ci.yml must run the e2e suite (npm run test:e2e)"
assert "working-directory: frontend" in ci, (
"ci.yml must run frontend steps in the frontend/ working directory"
)
# ── pyproject.toml completeness ──────────────────────────────────────────────

View file

@ -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) ─────────────────────────────────────

View file

@ -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("<svg></svg>\n")
(tmp_path / "frontend" / "src").mkdir(parents=True, exist_ok=True)
(tmp_path / "frontend" / "src" / "app.html").write_text(
"<!doctype html><html><head>"
'<meta name="viewport" content="width=device-width, initial-scale=1" />'
'<link rel="manifest" href="/manifest.webmanifest" />'
"</head><body></body></html>\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 ──────────────────────────────