Imported from enorm-labs/event-junkie (
events-frontend/AGENTS.md). Install upstream withnpx skills add enorm-labs/event-junkie --skill events-frontend. Copyright stays with the author.
AGENTS.md — events-frontend/
The conventions every change to the Vue SPA is held to. The nearest AGENTS.md wins, so this file overrides the repository root's for anything under
events-frontend/. The project is npm-managed, not a Gradle subproject, with its own workflow (build-frontend.yml: npm ci, lint, build, unit, e2e, on
Node 24).
The short version
npm run dev # Vite on 5173; /api proxies to the BFF, which must be on 8080
npm run type-check && npm run lint && npm run test:unit && npm run test:e2e # the gate, before any PR
npm run format # oxfmt; reformatting is intentional, never revert it
npm run generate:api # regenerate schema.d.ts — whenever the BFF's API changes
npm run test:a11y # the axe/WCAG sweep alone (a filter over test:e2e)
Four rules that catch most changes:
- A legal or About page is a document per language, not translated strings. Edit both languages or neither — docs/LEGAL.md §6.1.
- A change that adds a third-party request or stores anything on the visitor's device needs the privacy notice updated in the same PR, in both languages.
- Accessibility is WCAG 2.1 AA and it is linted — vue.instructions.md has the target and the two checks.
schema.d.tsis generated and committed, and nothing checks that it is current. A BFF API change that skipsnpm run generate:apileaves the frontend type-checking against an API that no longer exists.
Stack: Vue 3 (<script setup lang="ts"> only, no Options API), TypeScript 6 strict, Vite 8, Vue Router, Tailwind CSS v4 + shadcn-vue (ADR-010), oxlint +
eslint + oxfmt, Vitest (jsdom), Playwright. Do not add Prettier. Path alias @/ → src/; no semicolons, single quotes, no file extensions on imports
except the four below.
Node >=24.15.0 — a patch floor, forced by jsdom 30 (^22.22.2 || ^24.15.0 || >=26); a bare >=24 would let npm install succeed on a Node jsdom does
not support, with only an EBADENGINE warning. The floor moved before, for vue-i18n (ADR-013), and each move was forced, not chosen. A stale local Node
silently hides dependency updates: npm outdated filters out versions whose engines your interpreter fails, which is how jsdom 30 stayed invisible. A
quiet report means check node --version against .nvmrc first.
Path-scoped rules carry the rest and load with the matching files: vue.instructions.md (SFC structure,
Tailwind, shadcn-vue, accessibility), design.instructions.md (tokens, type scale),
testing.instructions.md (Vitest, Playwright, the locale strategy),
comments.instructions.md (max-comment-lines at 15, comment-density, comment-smell — the local
ESLint rules in eslint-rules/, on the ESLint side because oxlint takes no JS plugins).
Agent Instructions
- No unsolicited git commits, pushes or rebases. Only when the user asks.
npm run buildafter an implementation (vue-tsc + Vite), andnpm run lintandnpm run formatbefore finishing.- Layout:
src/{views,components,composables,lib,i18n,api,router,assets},components/ui/is vendored shadcn-vue (npx shadcn-vue add), tests colocated in__tests__/,e2e/for Playwright,injector/for the meta-injection sidecar (ADR-014: Node, no DOM, no Vue),scripts/for build-time generators. Composables areuse*, one per file, returningreadonly(ref)where consumers must not mutate;reffor primitives,reactivefor records (never destructured withouttoRefs()),shallowReffor wholesale-replaced data,markRawfor third-party instances. Routes insrc/router/index.ts, lazyimport()for non-critical ones, kebab-case names.
Config-loader imports (the .ts exception)
Four imports carry an explicit .ts extension: vite.config.ts → ./scripts/seoFiles.ts, vitest.config.ts → ./vite.config.ts, scripts/seoFiles.ts →
../src/lib/seo.ts, src/lib/seo.ts → ../i18n/locales.ts. Vite's config loader is moving to configLoader: 'native', Node's ESM resolver, where a
specifier means exactly what it says — transitively, down the whole chain a config file can reach. Keep the chain short: src/lib/seo.ts is the only
src/ module in it, which is why it is documented as free of browser globals. allowImportingTsExtensions (from @vue/tsconfig, explicit in
tsconfig.node.json) is what lets TypeScript accept them. The default loader is still bundle; --configLoader native works on Node 24 and fails on 22.
API Communication
Calls go through src/api/client.ts (openapi-fetch), typed from the generated schema — never bare fetch. Never hand-write a response type:
src/api/types.ts aliases the generated schemas (EventSummary, VenueDetail, …); use those, not components['schemas'][…]. Every generated field is
optional, because the BFF emits no required metadata — guard with optional chaining and defaults.
Regenerating schema.d.ts reads the running BFF's OpenAPI document; there is no offline mode:
./gradlew :events-bff:bootRun # or scripts/dev-env.sh up bff — restart it after editing a controller or DTO
npm run generate:api # events-frontend/
npx oxfmt src/api/schema.d.ts # BEFORE reading the diff
git diff src/api/schema.d.ts && npm run type-check
- A running but stale BFF succeeds and writes the schema for the API you didn't change. Restart first.
- The generator writes double quotes and semicolons; the committed file has neither. Unformatted, two added fields read as 1,304 insertions and 1,228 deletions; formatted, 96 insertions. Format before you read the diff.
- A rename lands as a delete plus an add, and surfaces as a type error in
types.ts— fix the alias, don't widen it. Removing or narrowing a field is a site break, not a type break: regenerating makes it compile, not render. Grep for the alias. - Never edit
schema.d.tsby hand. It covers the BFF only; the importer's admin API on:8081is not consumed.
Localisation
Every page lives under /<locale>/…; src/i18n/locales.ts is the single list of what is published (ADR-013).
- Every in-app link goes through
useLocalePath(). A bareto="/events"works via the catch-all redirect, and costs a redirect and a wrong URL flash. - Adding a locale means adding it to
LOCALESand shipping its catalogue in the same change — a locale is routable the moment it is listed, and a/deURL rendering English is worse than no/de. - User-facing strings belong in
src/i18n/messages/. The five long-form pages are the exception: About and/legal/*have one component per language (ImprintView.en.vue/.de.vue, wired throughlocalisedView()), because their prose carries inline links and markup JSON cannot hold, and a legal page has to be reviewable as a document. Edit both languages in the same change; shared facts (address, authority, review date) come fromsrc/lib/legal.ts, andviews/legal/__tests__/legalViews.spec.tsruns the mandatory-element checklist per language. German is authoritative (LEGAL.md §6.1), and both versions say so — do not remove that sentence. docker/nginx.conflogs no IP address; that is a privacy decision (#276, LEGAL.md §7.5). Itsej_no_ipformat overrides the base image'smain, whose last field is$http_x_forwarded_for— the visitor's real address behind Traefik. Adding either field back changes what the notice must declare.lib/format.tsstays pure (takes a locale);composables/useFormat.tssupplies it from i18n.todayIso()'sen-CAis a format, not a language — the shortest way toYYYY-MM-DD; making it locale-aware breaks every date filter silently. Event-type labels come fromeventType.*, withhumaniseEventType()as the fallback for a value the BFF enum gained first. Component tests get the i18n plugin fromsrc/test/setup.ts.
SEO surfaces
- A new static route decides whether it is indexable —
INDEXABLE_PATHSorNON_INDEXABLE_PATHSinsrc/lib/seo.ts; a unit test compares both against the router. It also needs adescriptionKey, or it falls back to the site-level description. sitemap.xmlandrobots.txtare generated byscripts/seoFiles.ts, at build and from the dev server. No copies underpublic/.- The sitemap is the primary
hreflangcarrier. The<link>tags inlib/seoTags.tsare script-injected and unreliable for crawlers; the injector writes them into served HTML for detail routes only, so an hreflang change that touches only the head tags has not shipped. - Canonical URLs come from
SITE_URL, neverwindow.location— otherwise every alias and preview declares itself canonical. - Title, description and image come from
src/lib/pageMeta.ts— nowhere else. The client and the injector (injector/, ADR-014 §Decision 3) both use it;usePageMeta.tsonly writes tags, andinjector/__tests__/parity.spec.tsproves both writers leave the same head. - The injector is a second Vite build and a second image:
vite.injector.config.tsbundlesinjector/server.tsintodist-injector/injector.mjs,Dockerfile.injectorships that file, it answers only the four detail route familiesnginx.confproxies to it, and it fails open. Everything it imports stays free of the DOM and of Vue —tsconfig.node.jsontype-checks it and has neither. - Entity descriptions are data and punctuation, never prose (
Fr., 12. Juni 2026 · Lido, Berlinneeds onlyIntl; the injector may run with no catalogue). Static pages takepageDescription.*from the catalogue. Omit a description rather than pad one. - Structured data (
src/lib/structuredData.ts) describes only what the page displays — Google policy. Omit rather than guess:eventJsonLdreturnsnullwhen a required field is absent, because partial structured data is rejected outright. Two claims are deliberately not made, for legal reasons: performers arePerformingGroup, neverPerson(§7.3, artist names are personal data), and the site is aWebSite, never anOrganization(the imprint names a private individual). JSON-LD is the one surface that does not wait on prerendering; Googlebot renders JavaScript.
Versioning
No file carries the application version (ADR-032): scripts/version.sh compute reads it from the release tags
and the commits since the last one, and the build stamps it. package.json holds 0.0.0, a placeholder — do not bump it, there is nothing to keep it in
step with. The footer's version comes from GET /meta, stamped by the Gradle build (useAppMeta.ts), never from package.json.
Open-source notices
/legal/notices renders src/assets/notices.json, generated and committed, never hand-edited. Regenerate whenever dependencies change on either side:
scripts/notices-parity.sh # repository root; both generators in the right order, then a diff
./gradlew generateLicenseReport --no-configuration-cache # the halves by hand: the Gradle report …
npm run generate:notices # … merged with npm's into notices.json
npm run generate:noticesmerges whatever Gradle report is on disk and cannot tell whether it is current — a month-old report produced 312 components where the answer was 368, exit 0 (#1084). Gradle'sUP-TO-DATEmakes the report's age no evidence; read the line the generator prints naming the report it merged and when it was written.- The generator writes no timestamp, so unchanged dependencies give an empty diff;
validate-notices.ymlruns the parity script whenever either ecosystem's declarations change (#1037). Bot PRs repair themselves (fix-notices-on-bot-prs.yml); that push stops Dependabot rebasing, so ifmainmoves under one, close and reopen it. - Licence policy is one policy in two files —
npm run check:licensesagainstconfig/allowed-licenses-npm.json,./gradlew checkLicense --no-configuration-cacheagainstconfig/allowed-licenses-jvm.json— because the ecosystems name licences differently.dependency-review.ymladds a deny-list on newly introduced dependencies. Do not widen an allow-list to make a build pass: AGPL, GPL without Classpath Exception, SSPL, BUSL, Elastic-2.0 are out (LEGAL.md §9.2); a genuine addition records why in the file's_rationale.
Screenshots go stale silently
docs/screenshots/ holds the README's pictures and nothing signals when one is wrong. Retake after changing App.vue, EventCard.vue, EventFilterBar.vue
or the theme tokens in main.css — docs/screenshots/README.md has the procedure. Not on a schedule, and not when the data
changes.
Testing
testing.instructions.md carries Vitest and Playwright conventions and the locale strategy, loaded with
e2e/** and **/__tests__/**.