Compare commits
4 commits
43975cb593
...
60451d7fa3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60451d7fa3 | ||
|
|
e1fd8f61f2 | ||
|
|
f0d71c3dd3 | ||
|
|
e4ddbb38af |
12 changed files with 189 additions and 2 deletions
|
|
@ -673,6 +673,50 @@ def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
|
||||||
return CheckResult(CheckStatus.OK, "frontend stack", "Tailwind + shadcn-svelte + TS detected")
|
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]:
|
def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
|
||||||
"""Type-specific extra checks beyond the expected dirs list."""
|
"""Type-specific extra checks beyond the expected dirs list."""
|
||||||
results: list[CheckResult] = []
|
results: list[CheckResult] = []
|
||||||
|
|
@ -688,6 +732,9 @@ def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[Che
|
||||||
frontend_stack = _check_frontend_stack(ctx)
|
frontend_stack = _check_frontend_stack(ctx)
|
||||||
if frontend_stack is not None:
|
if frontend_stack is not None:
|
||||||
results.append(frontend_stack)
|
results.append(frontend_stack)
|
||||||
|
mobile = _check_mobile_first(ctx)
|
||||||
|
if mobile is not None:
|
||||||
|
results.append(mobile)
|
||||||
if ptype == ProjectType.CLI:
|
if ptype == ProjectType.CLI:
|
||||||
results.append(_check_cli_package(ctx))
|
results.append(_check_cli_package(ctx))
|
||||||
flat = _check_flat_layout(ptype, ctx)
|
flat = _check_flat_layout(ptype, ctx)
|
||||||
|
|
|
||||||
|
|
@ -78,3 +78,18 @@ FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
|
||||||
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
|
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
|
||||||
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
|
"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"],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"@sveltejs/adapter-node": "^5.0.0",
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"@axe-core/playwright": "^4.10.0",
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
"knip": "^5.30.0",
|
"knip": "^5.30.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
{ name: 'mobile-chrome', use: { ...devices['iPhone SE'], isMobile: true, hasTouch: true } },
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run preview',
|
command: 'npm run preview',
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -258,6 +258,11 @@ def test_fullstack_structure(render):
|
||||||
assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists()
|
assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists()
|
||||||
assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists()
|
assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists()
|
||||||
assert (render / "frontend/tests/e2e/app.spec.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
|
# removed: counter.svelte.js, Header.svelte, jsconfig.json, +page.js
|
||||||
# root CI runs both
|
# root CI runs both
|
||||||
assert (render / ".github/workflows/ci.yml").exists()
|
assert (render / ".github/workflows/ci.yml").exists()
|
||||||
|
|
|
||||||
|
|
@ -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) ─────────────────────────────────────
|
# ── re-export sanity (backward compat) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,12 +241,28 @@ def _make_fullstack_repo(tmp_path: Path) -> None:
|
||||||
# Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS
|
# Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS
|
||||||
# (issue #266): package.json deps + components.json + tsconfig.json are the
|
# (issue #266): package.json deps + components.json + tsconfig.json are the
|
||||||
# 4 frontend stack markers checked by ``_check_frontend_stack``.
|
# 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(
|
(tmp_path / "frontend" / "package.json").write_text(
|
||||||
'{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", '
|
'{"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" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
|
||||||
(tmp_path / "frontend" / "components.json").write_text("{}\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 ─────────────────────────────────────────────────────────
|
# ── parse_remote_url ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -2243,6 +2259,45 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
|
||||||
assert "tsconfig.json" in frontend[0].detail
|
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 ──────────────────────────────
|
# ── issue #274: db/models auto-detect from deps ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue