From 98c990ac49851229264f6b22c2f664690a951cff Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 08:31:30 +0200 Subject: [PATCH 01/24] feat: placeholder frontpage + do-not-merge label workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thin `FrontpageController` mounted at `GET /` that renders a placeholder Twig template, identifying the project as ai-lib and signalling that the application is under construction. The template extends `templates/base.html.twig` from PR #41 and styles itself with Tailwind utilities — the asset pipeline introduced there is its first real consumer. A `WebTestCase` smoke test asserts `/` returns 200 and the response contains "ai-lib". The test file is committed ahead of #31; it will start running automatically once PHPUnit lands. Also adds `.github/workflows/block-on-label.yaml`: a per-PR merge gate that fails the check while a `do-not-merge` label is applied. Useful when a PR depends on another PR/release/review that needs to land first. The workflow only fails the check — making the merge button itself unavailable requires marking this check as a required status check in branch protection (out of scope for this PR). Refs #40 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/block-on-label.yaml | 39 ++++++++++++++++++++ CHANGELOG.md | 10 +++++ src/Controller/FrontpageController.php | 30 +++++++++++++++ templates/frontpage/index.html.twig | 23 ++++++++++++ tests/Controller/FrontpageControllerTest.php | 30 +++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 .github/workflows/block-on-label.yaml create mode 100644 src/Controller/FrontpageController.php create mode 100644 templates/frontpage/index.html.twig create mode 100644 tests/Controller/FrontpageControllerTest.php diff --git a/.github/workflows/block-on-label.yaml b/.github/workflows/block-on-label.yaml new file mode 100644 index 0000000..74ddf96 --- /dev/null +++ b/.github/workflows/block-on-label.yaml @@ -0,0 +1,39 @@ +### Block merge on label +### +### Fails the check while a `do-not-merge` label is applied to the +### pull request. The label is used to gate merges that depend on +### something landing elsewhere (another PR, an upstream release, a +### review by a specific team). +### +### Removing the label and re-running the check turns it green. To +### make the merge button itself unavailable while the label is set, +### this check has to be marked as a required status check in the +### repository's branch-protection rules. + +name: Block merge on label + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +jobs: + block-on-label: + name: Block on do-not-merge label + runs-on: ubuntu-latest + steps: + - name: Check for do-not-merge label + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + echo "Labels on this PR: $LABELS" + if echo "$LABELS" | grep -qx '"do-not-merge"' \ + || echo "$LABELS" | grep -q '"do-not-merge"'; then + echo "::error::This PR carries the 'do-not-merge' label. Remove the label once the blocking dependency is resolved." + exit 1 + fi + echo "No blocking label present." diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5ce70..2063c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,3 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Decision recorded in [ADR 002](docs/adr/002-frontend-tooling.md). - Base Twig layout (`templates/base.html.twig`) and frontend asset entrypoints (`assets/app.js`, `assets/styles/app.css`). +- Placeholder frontpage at `/` (`App\Controller\FrontpageController`) + extending the base layout. Identifies the project and signals that + the application is under construction. +- Functional smoke test for the frontpage + (`tests/Controller/FrontpageControllerTest.php`); will start running + once PHPUnit lands (#31). +- GitHub Action `block-on-label` that fails the check while a + `do-not-merge` label is applied to a pull request, providing a + per-PR merge gate for dependencies (e.g. another PR that must land + first). diff --git a/src/Controller/FrontpageController.php b/src/Controller/FrontpageController.php new file mode 100644 index 0000000..d7d99f4 --- /dev/null +++ b/src/Controller/FrontpageController.php @@ -0,0 +1,30 @@ +render('frontpage/index.html.twig'); + } +} diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig new file mode 100644 index 0000000..9cda1b8 --- /dev/null +++ b/templates/frontpage/index.html.twig @@ -0,0 +1,23 @@ +{% extends 'base.html.twig' %} + +{% block title %}ai-lib{% endblock %} + +{% block body %} +
+
+

+ ai-lib +

+

+ Under construction +

+

+ The catalogue is being scaffolded. There's nothing to see here + yet — check the + project repository + for progress. +

+
+
+{% endblock %} diff --git a/tests/Controller/FrontpageControllerTest.php b/tests/Controller/FrontpageControllerTest.php new file mode 100644 index 0000000..b7438c6 --- /dev/null +++ b/tests/Controller/FrontpageControllerTest.php @@ -0,0 +1,30 @@ +request('GET', '/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'ai-lib'); + } +} From de2c7cf5be766fcac388b5a0740b1d069e3aced3 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 10:08:51 +0200 Subject: [PATCH 02/24] feat: frontpage previews AI Bibliotek design with sample data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolves the placeholder frontpage from a single "under construction" message into a faithful preview of the AI Bibliotek prototype, so visitors get a first-glance impression of the catalogue before any real data exists. Mirrors the home view from `itk-dev/research-projects/docs/public/projects/ai-bibliotek/mocks`: - Hero with eyebrow, headline, lede paragraph, and three-stat block (assistant / kommune / model counts driven by the sample data). - Non-functional search prompt. - Horizontal rail of five sample assistants drawn from the prototype's seed data (Borgerservice-vejviser, Mødereferent, Journaliserings- assistent, Skole- og dagtilbudssvar, Tilsynsrapport-assistent). - "Sådan virker det" four-step ordered list. - "Kommer snart" chip grid for teased future features. The prototype's CSS is **converted to Tailwind v4 utilities** rather than imported verbatim. Design tokens (palette, fonts) move into `@theme` blocks in `assets/styles/app.css`, so they become Tailwind variants (`bg-surface`, `text-ink`, `font-display`, etc.). A small `@layer components` block keeps the pseudo-element and keyframe patterns that utility classes can't express (`eyebrow::before`, fade-up stagger, hamburger toggle). `templates/base.html.twig` gets the prototype's chrome — header with brand mark + responsive nav, footer, Fraunces/Geist fonts preloaded from Google Fonts. A new `nav_toggle_controller` Stimulus controller drives the mobile menu. Sample data lives in `FrontpageController` as `const SAMPLE_ASSISTANTS` — intentionally inline so the day this is replaced by a real `AssistantRepository`, the diff is small and obvious. Refs #40 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +- assets/controllers/nav_toggle_controller.js | 26 ++++ assets/styles/app.css | 152 +++++++++++++++++++ src/Controller/FrontpageController.php | 74 ++++++++- templates/base.html.twig | 63 +++++++- templates/frontpage/index.html.twig | 126 +++++++++++++-- tests/Controller/FrontpageControllerTest.php | 3 +- 7 files changed, 428 insertions(+), 29 deletions(-) create mode 100644 assets/controllers/nav_toggle_controller.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2063c7f..2f2bede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Base Twig layout (`templates/base.html.twig`) and frontend asset entrypoints (`assets/app.js`, `assets/styles/app.css`). - Placeholder frontpage at `/` (`App\Controller\FrontpageController`) - extending the base layout. Identifies the project and signals that - the application is under construction. + that previews the AI Bibliotek design with hardcoded sample data: + hero, search prompt, sample-assistant rail, "Sådan virker det" + steps, and "Kommer snart" chips. Follows the prototype mock at + `itk-dev/research-projects/docs/public/projects/ai-bibliotek/mocks`. +- Site chrome (header with brand + nav, footer) in + `templates/base.html.twig`, with the Fraunces/Geist font stack + preloaded from Google Fonts. +- Tailwind v4 design tokens (`@theme` in `assets/styles/app.css`) + matching the prototype palette and typography. +- Stimulus controller `nav_toggle_controller` driving the mobile + navigation menu. - Functional smoke test for the frontpage (`tests/Controller/FrontpageControllerTest.php`); will start running once PHPUnit lands (#31). diff --git a/assets/controllers/nav_toggle_controller.js b/assets/controllers/nav_toggle_controller.js new file mode 100644 index 0000000..9b97ae3 --- /dev/null +++ b/assets/controllers/nav_toggle_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Mobile navigation toggle. + * + * Mounted on the header container (`data-controller="nav-toggle"`). + * The toggle button uses `data-action="click->nav-toggle#toggle"` and + * the nav element is the `menu` target. Flips `aria-expanded` on the + * button and the `hidden` utility class on the menu so screen readers + * and the CSS toggle effect stay in sync. + */ +export default class extends Controller { + static targets = ["menu"]; + + toggle(event) { + const button = event.currentTarget; + const expanded = button.getAttribute("aria-expanded") === "true"; + button.setAttribute("aria-expanded", String(!expanded)); + + if (!this.hasMenuTarget) { + return; + } + this.menuTarget.classList.toggle("hidden", expanded); + this.menuTarget.classList.toggle("flex", !expanded); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f1d8c73..9543d24 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1 +1,153 @@ @import "tailwindcss"; + +/* + * Design tokens mirroring the AI Bibliotek prototype + * (https://github.com/itk-dev/research-projects/tree/main/docs/public/projects/ai-bibliotek/mocks). + * Registered via Tailwind v4's `@theme` so the values become utility + * variants — e.g. `bg-surface`, `text-ink`, `font-display`. + */ +@theme { + /* Surface + ink palette */ + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-surface-2: #fbf9f5; + --color-border: #e3ddd1; + --color-border-strong: #c8bea9; + --color-line: #ddd5c4; + --color-ink: #11192a; + --color-text: #1c2433; + --color-text-muted: #5b6478; + + /* Brand */ + --color-primary: #0f766e; + --color-primary-hover: #0c5e57; + --color-primary-ink: #ffffff; + --color-accent: #c89b3c; + --color-accent-soft: #f0e3c2; + --color-accent-deep: #8a6a1f; + + /* Typography */ + --font-display: + "Fraunces", "Source Serif Pro", "Iowan Old Style", Georgia, serif; + --font-sans: + "Geist", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Helvetica, Arial, sans-serif; + --font-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + monospace; +} + +/* + * Base layer: page chrome that has to apply to regardless of + * which utility classes the template carries. Mirrors the prototype's + * body styling so the same font stack and ink colour are used + * everywhere. + */ +@layer base { + body { + font-family: var(--font-sans); + color: var(--color-text); + background-color: var(--color-bg); + font-feature-settings: "ss01", "ss02"; + } + + h1, + h2, + h3, + h4 { + font-family: var(--font-display); + font-weight: 500; + color: var(--color-ink); + letter-spacing: -0.01em; + line-height: 1.15; + } +} + +/* + * Component layer: small set of compound patterns that would be + * unwieldy as inline utility lists. Each is named after its + * prototype counterpart and only contains decorative bits that + * Tailwind utilities don't express directly (pseudo-elements, + * keyframes). + */ +@layer components { + /* Eyebrow label with leading decorative rule, used as a section kicker. */ + .eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent-deep); + } + .eyebrow::before { + content: ""; + display: inline-block; + width: 24px; + height: 1px; + background-color: var(--color-accent-deep); + } + + /* Staggered fade-up on the main view's direct children. */ + @keyframes fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .view-root > * { + animation: fade-up 220ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + .view-root > *:nth-child(1) { + animation-delay: 0ms; + } + .view-root > *:nth-child(2) { + animation-delay: 40ms; + } + .view-root > *:nth-child(3) { + animation-delay: 80ms; + } + .view-root > *:nth-child(4) { + animation-delay: 120ms; + } + .view-root > *:nth-child(5) { + animation-delay: 160ms; + } + @media (prefers-reduced-motion: reduce) { + .view-root > * { + animation: none; + } + } + + /* + * Animated hamburger toggle. The three bars rotate into an X when + * `aria-expanded="true"`. Hand-rolled because Tailwind utilities + * can't express child-element transforms tied to a parent ARIA + * state. + */ + .nav-toggle-bar { + display: block; + width: 18px; + height: 2px; + background-color: var(--color-ink); + border-radius: 2px; + transition: + transform 220ms ease, + opacity 220ms ease; + } + .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) { + transform: translateY(5.5px) rotate(45deg); + } + .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) { + opacity: 0; + } + .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) { + transform: translateY(-5.5px) rotate(-45deg); + } +} diff --git a/src/Controller/FrontpageController.php b/src/Controller/FrontpageController.php index d7d99f4..8f2b8fd 100644 --- a/src/Controller/FrontpageController.php +++ b/src/Controller/FrontpageController.php @@ -10,21 +10,81 @@ class FrontpageController extends AbstractController { + /** + * Hardcoded placeholder assistants used by the design preview. + * + * Drawn from the AI Bibliotek prototype's seed data to give an + * accurate first-glance impression of the catalogue. Replaced by + * a real repository query once the persistence layer lands. + */ + private const SAMPLE_ASSISTANTS = [ + [ + 'kommune' => 'Aarhus Kommune', + 'model' => 'gpt-4o', + 'name' => 'Borgerservice-vejviser', + 'summary' => 'Hjælper sagsbehandlere med at finde den rigtige paragraf i lov om social service og opsummere borgerens situation.', + ], + [ + 'kommune' => 'Københavns Kommune', + 'model' => 'claude-3.5-sonnet', + 'name' => 'Mødereferent', + 'summary' => 'Tager udgangspunkt i et indtalt møde og leverer struktureret referat med beslutninger, ansvar og deadlines.', + ], + [ + 'kommune' => 'Odense Kommune', + 'model' => 'llama-3.1-70b', + 'name' => 'Journaliseringsassistent', + 'summary' => 'Foreslår journalplan-numre og overskrifter ud fra dokumentets indhold, så fagmedarbejdere kan godkende i et klik.', + ], + [ + 'kommune' => 'Vejle Kommune', + 'model' => 'gpt-4o-mini', + 'name' => 'Skole- og dagtilbudssvar', + 'summary' => 'Drafter svar til forældrehenvendelser på skole- og dagtilbudsområdet med kildehenvisninger til kommunens egen vejledningssamling.', + ], + [ + 'kommune' => 'Aalborg Kommune', + 'model' => 'mistral-large', + 'name' => 'Tilsynsrapport-assistent', + 'summary' => 'Læser plejehjemstilsynsrapporter og fremhæver afvigelser, opfølgningspunkter og forbedringer over tid.', + ], + ]; + + private const COMING_SOON = [ + 'Deling af tools', + 'Deling af skills', + 'Ratings', + 'API', + 'Abonnér på ændringer', + 'Testcases', + ]; + /** * Render the placeholder frontpage. * - * Anonymous visitors to `/` receive a thin landing page that - * identifies the project (ai-lib) and signals that the - * application is under construction. The page exists so that - * other UI work (auth, catalogue, search) has a stable entry - * point to link back to while the richer frontpage envisioned - * in #3 is being designed. + * Anonymous visitors to `/` receive a design-preview landing page + * that mirrors the AI Bibliotek prototype. Hero, search prompt, + * sample-assistant rail, "Sådan virker det" steps, and "Kommer + * snart" chips are rendered with hardcoded sample data — the + * point is to convey what the catalogue will feel like before + * the persistence and search layers land. * * @return Response the rendered `frontpage/index.html.twig` template */ #[Route('/', name: 'app_frontpage', methods: ['GET'])] public function index(): Response { - return $this->render('frontpage/index.html.twig'); + $kommuner = array_unique(array_column(self::SAMPLE_ASSISTANTS, 'kommune')); + $models = array_unique(array_column(self::SAMPLE_ASSISTANTS, 'model')); + + return $this->render('frontpage/index.html.twig', [ + 'assistants' => self::SAMPLE_ASSISTANTS, + 'stats' => [ + 'assistants' => count(self::SAMPLE_ASSISTANTS), + 'kommuner' => count($kommuner), + 'models' => count($models), + ], + 'coming_soon' => self::COMING_SOON, + ]); } } diff --git a/templates/base.html.twig b/templates/base.html.twig index c223fd4..5012731 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,10 +1,15 @@ - + - {% block title %}ai-lib{% endblock %} + {% block title %}AI Bibliotek{% endblock %} + + + + + {% block stylesheets %} {% endblock %} @@ -13,7 +18,57 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} - - {% block body %}{% endblock %} + + + Spring til indhold + + +
+ +
+ +
+ {% block body %}{% endblock %} +
+ + diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index 9cda1b8..71a8a41 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -1,23 +1,119 @@ {% extends 'base.html.twig' %} -{% block title %}ai-lib{% endblock %} +{% block title %}AI Bibliotek – forhåndsvisning{% endblock %} {% block body %} -
-
-

- ai-lib -

-

- Under construction +
+ + {# Hero #} +
+

Del & hjemtag · dansk offentlig AI

+

+ Et fælles bibliotek over kommunale AI-assistenter.

-

- The catalogue is being scaffolded. There's nothing to see here - yet — check the - project repository - for progress. +

+ Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser + en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den + lokalt — så gode løsninger skalerer nationalt. +

+
+
+
Assistenter
+
{{ stats.assistants }}
+
+
+
Kommuner
+
{{ stats.kommuner }}
+
+
+
Sprogmodeller
+
{{ stats.models }}
+
+
+
+ + {# Search #} +
+

Find en assistent

+ +
+ + {# Recent assistants rail #} +
+
+

Forhåndsvisning

+ Kataloget kommer → +
+ +
+ + {# How it works #} +
+

Sådan virker det

+
    + {% set steps = [ + {lead: 'Opret bruger', body: ' og log ind som repræsentant for din myndighed.'}, + {lead: 'Find en assistent', body: ' – søg og filtrér på kommune, sprogmodel og datafølsomhed.'}, + {lead: 'Hjemtag', body: ' – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere.'}, + {lead: 'Tilpas lokalt', body: ' – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug.'}, + ] %} + {% for step in steps %} +
  1. + {{ loop.index }} + + {{ step.lead }}{{ step.body }} + +
  2. + {% endfor %} +
+
+ + {# Coming soon #} +
+

Kommer snart

+

+ Funktioner vi arbejder på til kommende versioner. De er ikke aktive endnu.

+
+ {% for label in coming_soon %} + + {{ label }} + snart + + {% endfor %} +
-

+ + {% endblock %} diff --git a/tests/Controller/FrontpageControllerTest.php b/tests/Controller/FrontpageControllerTest.php index b7438c6..25d57b5 100644 --- a/tests/Controller/FrontpageControllerTest.php +++ b/tests/Controller/FrontpageControllerTest.php @@ -25,6 +25,7 @@ public function testFrontpageReturns200AndShowsProjectName(): void $client->request('GET', '/'); self::assertResponseIsSuccessful(); - self::assertSelectorTextContains('body', 'ai-lib'); + self::assertSelectorTextContains('body', 'AI Bibliotek'); + self::assertSelectorTextContains('h1', 'kommunale'); } } From d3cb04fefffb7b1f7afc854e9c3c3e3694c186f7 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 12:48:30 +0200 Subject: [PATCH 03/24] feat: split frontpage into anonymous Twig components Install symfony/ux-twig-component and refactor the placeholder frontpage and base layout into 17 anonymous components under templates/components/ (Layout, Nav, Hero, Stats, SearchBox, CardRail, Box, StepList, TagList, Eyebrow). Rendered HTML and Stimulus / CSS hooks are unchanged; future sections can compose the same primitives instead of duplicating utility lists. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 1 + composer.lock | 339 +++++++++++++++++- config/bundles.php | 1 + config/packages/property_info.yaml | 3 + config/packages/twig_component.yaml | 5 + config/reference.php | 21 +- symfony.lock | 24 ++ templates/base.html.twig | 58 +-- templates/components/Box.html.twig | 8 + templates/components/CardRail/Card.html.twig | 8 + .../components/CardRail/Container.html.twig | 13 + templates/components/Eyebrow.html.twig | 2 + templates/components/Hero.html.twig | 11 + templates/components/Layout/Brand.html.twig | 8 + .../components/Layout/SiteFooter.html.twig | 7 + .../components/Layout/SiteHeader.html.twig | 9 + .../components/Layout/SkipLink.html.twig | 4 + templates/components/Nav/Link.html.twig | 2 + templates/components/Nav/Menu.html.twig | 7 + templates/components/Nav/Toggle.html.twig | 11 + templates/components/SearchBox.html.twig | 21 ++ templates/components/Stats/Item.html.twig | 5 + templates/components/Stats/List.html.twig | 3 + templates/components/StepList/Item.html.twig | 7 + templates/components/StepList/List.html.twig | 3 + templates/components/TagList/List.html.twig | 4 + templates/components/TagList/Tag.html.twig | 7 + templates/frontpage/index.html.twig | 141 +++----- 28 files changed, 585 insertions(+), 148 deletions(-) create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/twig_component.yaml create mode 100644 templates/components/Box.html.twig create mode 100644 templates/components/CardRail/Card.html.twig create mode 100644 templates/components/CardRail/Container.html.twig create mode 100644 templates/components/Eyebrow.html.twig create mode 100644 templates/components/Hero.html.twig create mode 100644 templates/components/Layout/Brand.html.twig create mode 100644 templates/components/Layout/SiteFooter.html.twig create mode 100644 templates/components/Layout/SiteHeader.html.twig create mode 100644 templates/components/Layout/SkipLink.html.twig create mode 100644 templates/components/Nav/Link.html.twig create mode 100644 templates/components/Nav/Menu.html.twig create mode 100644 templates/components/Nav/Toggle.html.twig create mode 100644 templates/components/SearchBox.html.twig create mode 100644 templates/components/Stats/Item.html.twig create mode 100644 templates/components/Stats/List.html.twig create mode 100644 templates/components/StepList/Item.html.twig create mode 100644 templates/components/StepList/List.html.twig create mode 100644 templates/components/TagList/List.html.twig create mode 100644 templates/components/TagList/Tag.html.twig diff --git a/composer.json b/composer.json index 99c0c7b..ef0bb2f 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "symfony/runtime": "~8.1.0", "symfony/stimulus-bundle": "^3.1", "symfony/twig-bundle": "~8.1.0", + "symfony/ux-twig-component": "^3.1", "symfony/yaml": "~8.1.0", "symfonycasts/tailwind-bundle": "^0.13.0", "twig/extra-bundle": "^2.12 || ^3.0", diff --git a/composer.lock b/composer.lock index fca4d2b..50094e0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f9d6a9dd93b8a04d2d845a9caec0864", + "content-hash": "ae83a4ba287dd9b635c1649586e81003", "packages": [ { "name": "composer/semver", @@ -2474,6 +2474,173 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/property-access", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/routing", "version": "v8.1.0", @@ -3165,6 +3332,176 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/ux-twig-component", + "version": "v3.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/69763f39367d7185ebff3a8aec3e9d6ec7e10d55", + "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<6.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^7.4|^8.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v3.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T07:08:56+00:00" + }, { "name": "symfony/var-dumper", "version": "v8.1.0", diff --git a/config/bundles.php b/config/bundles.php index 1a6d22b..76e2980 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -6,4 +6,5 @@ Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], ]; diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 0000000..fd17ac6 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,5 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' diff --git a/config/reference.php b/config/reference.php index 14b81cc..aeba6ad 100644 --- a/config/reference.php +++ b/config/reference.php @@ -369,7 +369,7 @@ * }>, * }, * property_access?: bool|array{ // Property access configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * magic_call?: bool|Param, // Default: false * magic_get?: bool|Param, // Default: true * magic_set?: bool|Param, // Default: true @@ -377,11 +377,11 @@ * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true * }, * cache?: array{ // Cache configuration @@ -794,6 +794,17 @@ * strict_mode?: bool|Param|null, // When enabled, an exception will be thrown if there are no built assets (default: false in `test` env, true otherwise) // Default: null * process_timeout?: int|Param, // Timeout in seconds for the Tailwind build process - use "0" to disable // Default: 60 * } + * @psalm-type TwigComponentConfig = array{ + * defaults?: array, + * anonymous_template_directory?: scalar|Param|null, // Defaults to `components` + * profiler?: bool|array{ // Enables the profiler for Twig Component + * enabled?: bool|Param, // Default: "%kernel.debug%" + * collect_components?: bool|Param, // Collect components instances // Default: true + * }, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -803,6 +814,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -812,6 +824,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -822,6 +835,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -832,6 +846,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * }, * ... - - Spring til indhold - - -
- -
+ + + + + Katalog + Del assistent + Mine assistenter + Favoritter + Samlinger + +
- + + Forhåndsvisning · ingen data endnu · designet følger prototype-mockuppet + diff --git a/templates/components/Box.html.twig b/templates/components/Box.html.twig new file mode 100644 index 0000000..abd66ee --- /dev/null +++ b/templates/components/Box.html.twig @@ -0,0 +1,8 @@ +{% props eyebrow, lead = null %} +
+ {{ eyebrow }} + {% if lead %} +

{{ lead }}

+ {% endif %} + {% block content %}{% endblock %} +
diff --git a/templates/components/CardRail/Card.html.twig b/templates/components/CardRail/Card.html.twig new file mode 100644 index 0000000..f9bac1d --- /dev/null +++ b/templates/components/CardRail/Card.html.twig @@ -0,0 +1,8 @@ +{% props kommune, model, name, summary, href = '#' %} + + {{ kommune }} · {{ model }} + {{ name }} + {{ summary }} + diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig new file mode 100644 index 0000000..ab0e8c5 --- /dev/null +++ b/templates/components/CardRail/Container.html.twig @@ -0,0 +1,13 @@ +{% props eyebrow, linkHref = '#', linkLabel, listLabel = 'Eksempler på assistenter' %} +
+
+ {{ eyebrow }} + {{ linkLabel }} +
+
+ {% block content %}{% endblock %} +
+
diff --git a/templates/components/Eyebrow.html.twig b/templates/components/Eyebrow.html.twig new file mode 100644 index 0000000..802246a --- /dev/null +++ b/templates/components/Eyebrow.html.twig @@ -0,0 +1,2 @@ +{% props class = '' %} +

{% block content %}{% endblock %}

diff --git a/templates/components/Hero.html.twig b/templates/components/Hero.html.twig new file mode 100644 index 0000000..eb28487 --- /dev/null +++ b/templates/components/Hero.html.twig @@ -0,0 +1,11 @@ +{% props eyebrow %} +
+ {{ eyebrow|raw }} +

+ {% block heading %}{% endblock %} +

+

+ {% block lead %}{% endblock %} +

+ {% block content %}{% endblock %} +
diff --git a/templates/components/Layout/Brand.html.twig b/templates/components/Layout/Brand.html.twig new file mode 100644 index 0000000..18e89b0 --- /dev/null +++ b/templates/components/Layout/Brand.html.twig @@ -0,0 +1,8 @@ +{% props href, initials = 'AI', name = 'AI Bibliotek', tagline = 'del & hjemtag assistenter · prototype' %} + + + + {{ name }} + {{ tagline|raw }} + + diff --git a/templates/components/Layout/SiteFooter.html.twig b/templates/components/Layout/SiteFooter.html.twig new file mode 100644 index 0000000..6109857 --- /dev/null +++ b/templates/components/Layout/SiteFooter.html.twig @@ -0,0 +1,7 @@ +{% props linkHref, linkLabel %} + diff --git a/templates/components/Layout/SiteHeader.html.twig b/templates/components/Layout/SiteHeader.html.twig new file mode 100644 index 0000000..2189fc3 --- /dev/null +++ b/templates/components/Layout/SiteHeader.html.twig @@ -0,0 +1,9 @@ +{# Header shell. Default slot receives the nav menu. #} +
+
+ + + {% block content %}{% endblock %} +
+
diff --git a/templates/components/Layout/SkipLink.html.twig b/templates/components/Layout/SkipLink.html.twig new file mode 100644 index 0000000..0558afd --- /dev/null +++ b/templates/components/Layout/SkipLink.html.twig @@ -0,0 +1,4 @@ +{% props href = '#main', label = 'Spring til indhold' %} + + {{ label }} + diff --git a/templates/components/Nav/Link.html.twig b/templates/components/Nav/Link.html.twig new file mode 100644 index 0000000..409fb22 --- /dev/null +++ b/templates/components/Nav/Link.html.twig @@ -0,0 +1,2 @@ +{% props href %} +{% block content %}{% endblock %} diff --git a/templates/components/Nav/Menu.html.twig b/templates/components/Nav/Menu.html.twig new file mode 100644 index 0000000..8f7ebcb --- /dev/null +++ b/templates/components/Nav/Menu.html.twig @@ -0,0 +1,7 @@ +{% props id = 'primary-nav', label = 'Hovedmenu' %} + diff --git a/templates/components/Nav/Toggle.html.twig b/templates/components/Nav/Toggle.html.twig new file mode 100644 index 0000000..cdea2a2 --- /dev/null +++ b/templates/components/Nav/Toggle.html.twig @@ -0,0 +1,11 @@ +{# Hamburger button. Class/ARIA/data-action are referenced by app.css and nav_toggle_controller.js — preserve verbatim. #} + diff --git a/templates/components/SearchBox.html.twig b/templates/components/SearchBox.html.twig new file mode 100644 index 0000000..6611af8 --- /dev/null +++ b/templates/components/SearchBox.html.twig @@ -0,0 +1,21 @@ +{% props legend = 'Find en assistent', placeholder, submitLabel = 'Søg', action = '#', name = 'q', inputId = 'frontpage-search' %} +
+ {{ legend }} + +
diff --git a/templates/components/Stats/Item.html.twig b/templates/components/Stats/Item.html.twig new file mode 100644 index 0000000..3c682df --- /dev/null +++ b/templates/components/Stats/Item.html.twig @@ -0,0 +1,5 @@ +{% props label, value %} +
+
{{ label }}
+
{{ value }}
+
diff --git a/templates/components/Stats/List.html.twig b/templates/components/Stats/List.html.twig new file mode 100644 index 0000000..e77e55c --- /dev/null +++ b/templates/components/Stats/List.html.twig @@ -0,0 +1,3 @@ +
+ {% block content %}{% endblock %} +
diff --git a/templates/components/StepList/Item.html.twig b/templates/components/StepList/Item.html.twig new file mode 100644 index 0000000..e62b18c --- /dev/null +++ b/templates/components/StepList/Item.html.twig @@ -0,0 +1,7 @@ +{% props index, lead, body %} +
  • + {{ index }} + + {{ lead }}{{ body }} + +
  • diff --git a/templates/components/StepList/List.html.twig b/templates/components/StepList/List.html.twig new file mode 100644 index 0000000..f1ea3cf --- /dev/null +++ b/templates/components/StepList/List.html.twig @@ -0,0 +1,3 @@ +
      + {% block content %}{% endblock %} +
    diff --git a/templates/components/TagList/List.html.twig b/templates/components/TagList/List.html.twig new file mode 100644 index 0000000..7948ffa --- /dev/null +++ b/templates/components/TagList/List.html.twig @@ -0,0 +1,4 @@ +{% props label %} +
    + {% block content %}{% endblock %} +
    diff --git a/templates/components/TagList/Tag.html.twig b/templates/components/TagList/Tag.html.twig new file mode 100644 index 0000000..f2683ac --- /dev/null +++ b/templates/components/TagList/Tag.html.twig @@ -0,0 +1,7 @@ +{% props label, badge = 'snart', title = 'Kommer snart' %} + + {{ label }} + {{ badge }} + diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index 71a8a41..6aac85e 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -5,115 +5,56 @@ {% block body %}
    - {# Hero #} -
    -

    Del & hjemtag · dansk offentlig AI

    -

    + + Et fælles bibliotek over kommunale AI-assistenter. -

    -

    + + Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt. -

    -
    -
    -
    Assistenter
    -
    {{ stats.assistants }}
    -
    -
    -
    Kommuner
    -
    {{ stats.kommuner }}
    -
    -
    -
    Sprogmodeller
    -
    {{ stats.models }}
    -
    -
    -
    - - {# Search #} -
    -

    Find en assistent

    - -
    - - {# Recent assistants rail #} -
    -
    -

    Forhåndsvisning

    - Kataloget kommer → -
    - -
    - - {# How it works #} -
    -

    Sådan virker det

    -
      - {% set steps = [ - {lead: 'Opret bruger', body: ' og log ind som repræsentant for din myndighed.'}, - {lead: 'Find en assistent', body: ' – søg og filtrér på kommune, sprogmodel og datafølsomhed.'}, - {lead: 'Hjemtag', body: ' – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere.'}, - {lead: 'Tilpas lokalt', body: ' – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug.'}, - ] %} + + + + + + + + + + + + + {% for a in assistants %} + + {% endfor %} + + + + {% set steps = [ + {lead: 'Opret bruger', body: ' og log ind som repræsentant for din myndighed.'}, + {lead: 'Find en assistent', body: ' – søg og filtrér på kommune, sprogmodel og datafølsomhed.'}, + {lead: 'Hjemtag', body: ' – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere.'}, + {lead: 'Tilpas lokalt', body: ' – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug.'}, + ] %} + {% for step in steps %} -
    1. - {{ loop.index }} - - {{ step.lead }}{{ step.body }} - -
    2. + {% endfor %} -
    -
    + + - {# Coming soon #} -
    -

    Kommer snart

    -

    - Funktioner vi arbejder på til kommende versioner. De er ikke aktive endnu. -

    -
    + + {% for label in coming_soon %} - - {{ label }} - snart - + {% endfor %} -
    -
    + +
    {% endblock %} From 59adc1cdaaba8c1f1c27f4c0276e748d87241b98 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 13:19:34 +0200 Subject: [PATCH 04/24] feat: emit dev-mode HTML comments around every Twig template Wrap each template's output in begin + end HTML comments that print the template path via Twig's _self variable. Gated on app.environment == 'dev' so prod output is unchanged. Makes it trivial to identify which template produced any DOM region from browser DevTools. Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/base.html.twig | 4 +++- templates/components/Box.html.twig | 2 ++ templates/components/CardRail/Card.html.twig | 2 ++ templates/components/CardRail/Container.html.twig | 2 ++ templates/components/Eyebrow.html.twig | 2 ++ templates/components/Hero.html.twig | 2 ++ templates/components/Layout/Brand.html.twig | 2 ++ templates/components/Layout/SiteFooter.html.twig | 2 ++ templates/components/Layout/SiteHeader.html.twig | 2 ++ templates/components/Layout/SkipLink.html.twig | 2 ++ templates/components/Nav/Link.html.twig | 2 ++ templates/components/Nav/Menu.html.twig | 2 ++ templates/components/Nav/Toggle.html.twig | 2 ++ templates/components/SearchBox.html.twig | 2 ++ templates/components/Stats/Item.html.twig | 2 ++ templates/components/Stats/List.html.twig | 2 ++ templates/components/StepList/Item.html.twig | 2 ++ templates/components/StepList/List.html.twig | 2 ++ templates/components/TagList/List.html.twig | 2 ++ templates/components/TagList/Tag.html.twig | 2 ++ templates/frontpage/index.html.twig | 2 ++ 21 files changed, 43 insertions(+), 1 deletion(-) diff --git a/templates/base.html.twig b/templates/base.html.twig index 1469cf8..24a6be9 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,4 +1,5 @@ - +{% if app.environment == 'dev' %} +{% endif %} @@ -42,3 +43,4 @@ +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Box.html.twig b/templates/components/Box.html.twig index abd66ee..2fc7011 100644 --- a/templates/components/Box.html.twig +++ b/templates/components/Box.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props eyebrow, lead = null %}
    {{ eyebrow }} @@ -6,3 +7,4 @@ {% endif %} {% block content %}{% endblock %}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/CardRail/Card.html.twig b/templates/components/CardRail/Card.html.twig index f9bac1d..1373f6f 100644 --- a/templates/components/CardRail/Card.html.twig +++ b/templates/components/CardRail/Card.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props kommune, model, name, summary, href = '#' %} {{ name }} {{ summary }} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig index ab0e8c5..bd8ad3e 100644 --- a/templates/components/CardRail/Container.html.twig +++ b/templates/components/CardRail/Container.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props eyebrow, linkHref = '#', linkLabel, listLabel = 'Eksempler på assistenter' %}
    @@ -11,3 +12,4 @@ {% block content %}{% endblock %}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Eyebrow.html.twig b/templates/components/Eyebrow.html.twig index 802246a..2882e7e 100644 --- a/templates/components/Eyebrow.html.twig +++ b/templates/components/Eyebrow.html.twig @@ -1,2 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props class = '' %}

    {% block content %}{% endblock %}

    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Hero.html.twig b/templates/components/Hero.html.twig index eb28487..e67303c 100644 --- a/templates/components/Hero.html.twig +++ b/templates/components/Hero.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props eyebrow %}
    {{ eyebrow|raw }} @@ -9,3 +10,4 @@

    {% block content %}{% endblock %}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/Brand.html.twig b/templates/components/Layout/Brand.html.twig index 18e89b0..92c66c7 100644 --- a/templates/components/Layout/Brand.html.twig +++ b/templates/components/Layout/Brand.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props href, initials = 'AI', name = 'AI Bibliotek', tagline = 'del & hjemtag assistenter · prototype' %} @@ -6,3 +7,4 @@ {{ tagline|raw }} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/SiteFooter.html.twig b/templates/components/Layout/SiteFooter.html.twig index 6109857..b91fee1 100644 --- a/templates/components/Layout/SiteFooter.html.twig +++ b/templates/components/Layout/SiteFooter.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props linkHref, linkLabel %} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/SiteHeader.html.twig b/templates/components/Layout/SiteHeader.html.twig index 2189fc3..83d6336 100644 --- a/templates/components/Layout/SiteHeader.html.twig +++ b/templates/components/Layout/SiteHeader.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {# Header shell. Default slot receives the nav menu. #}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/SkipLink.html.twig b/templates/components/Layout/SkipLink.html.twig index 0558afd..bab4187 100644 --- a/templates/components/Layout/SkipLink.html.twig +++ b/templates/components/Layout/SkipLink.html.twig @@ -1,4 +1,6 @@ +{% if app.environment == 'dev' %}{% endif %} {% props href = '#main', label = 'Spring til indhold' %} {{ label }} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Nav/Link.html.twig b/templates/components/Nav/Link.html.twig index 409fb22..5de7459 100644 --- a/templates/components/Nav/Link.html.twig +++ b/templates/components/Nav/Link.html.twig @@ -1,2 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props href %} {% block content %}{% endblock %} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Nav/Menu.html.twig b/templates/components/Nav/Menu.html.twig index 8f7ebcb..0cc0ce7 100644 --- a/templates/components/Nav/Menu.html.twig +++ b/templates/components/Nav/Menu.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props id = 'primary-nav', label = 'Hovedmenu' %} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Nav/Toggle.html.twig b/templates/components/Nav/Toggle.html.twig index cdea2a2..3fb461f 100644 --- a/templates/components/Nav/Toggle.html.twig +++ b/templates/components/Nav/Toggle.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {# Hamburger button. Class/ARIA/data-action are referenced by app.css and nav_toggle_controller.js — preserve verbatim. #} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/SearchBox.html.twig b/templates/components/SearchBox.html.twig index 6611af8..6fa0f1c 100644 --- a/templates/components/SearchBox.html.twig +++ b/templates/components/SearchBox.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props legend = 'Find en assistent', placeholder, submitLabel = 'Søg', action = '#', name = 'q', inputId = 'frontpage-search' %}
    {{ legend }} @@ -19,3 +20,4 @@
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Stats/Item.html.twig b/templates/components/Stats/Item.html.twig index 3c682df..653c9f2 100644 --- a/templates/components/Stats/Item.html.twig +++ b/templates/components/Stats/Item.html.twig @@ -1,5 +1,7 @@ +{% if app.environment == 'dev' %}{% endif %} {% props label, value %}
    {{ label }}
    {{ value }}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Stats/List.html.twig b/templates/components/Stats/List.html.twig index e77e55c..7885265 100644 --- a/templates/components/Stats/List.html.twig +++ b/templates/components/Stats/List.html.twig @@ -1,3 +1,5 @@ +{% if app.environment == 'dev' %}{% endif %}
    {% block content %}{% endblock %}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/StepList/Item.html.twig b/templates/components/StepList/Item.html.twig index e62b18c..b7f8358 100644 --- a/templates/components/StepList/Item.html.twig +++ b/templates/components/StepList/Item.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props index, lead, body %}
  • {{ index }} @@ -5,3 +6,4 @@ {{ lead }}{{ body }}
  • +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/StepList/List.html.twig b/templates/components/StepList/List.html.twig index f1ea3cf..245469a 100644 --- a/templates/components/StepList/List.html.twig +++ b/templates/components/StepList/List.html.twig @@ -1,3 +1,5 @@ +{% if app.environment == 'dev' %}{% endif %}
      {% block content %}{% endblock %}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/TagList/List.html.twig b/templates/components/TagList/List.html.twig index 7948ffa..2710384 100644 --- a/templates/components/TagList/List.html.twig +++ b/templates/components/TagList/List.html.twig @@ -1,4 +1,6 @@ +{% if app.environment == 'dev' %}{% endif %} {% props label %}
    {% block content %}{% endblock %}
    +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/TagList/Tag.html.twig b/templates/components/TagList/Tag.html.twig index f2683ac..68cb0a4 100644 --- a/templates/components/TagList/Tag.html.twig +++ b/templates/components/TagList/Tag.html.twig @@ -1,3 +1,4 @@ +{% if app.environment == 'dev' %}{% endif %} {% props label, badge = 'snart', title = 'Kommer snart' %} {{ badge }} +{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index 6aac85e..00b00eb 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -3,6 +3,7 @@ {% block title %}AI Bibliotek – forhåndsvisning{% endblock %} {% block body %} + {% if app.environment == 'dev' %}{% endif %}
    @@ -57,4 +58,5 @@
    + {% if app.environment == 'dev' %}{% endif %} {% endblock %} From c12253e822af777e71af2ebd04cc0dce53f5336b Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 13:20:09 +0200 Subject: [PATCH 05/24] refactor: drop Kommer snart section from frontpage Removes the Coming Soon chip list from templates/frontpage/index.html.twig and the now-unused COMING_SOON const + template variable from FrontpageController. The TagList components stay in templates/components/ as reusable primitives. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Controller/FrontpageController.php | 18 ++++-------------- templates/frontpage/index.html.twig | 8 -------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/Controller/FrontpageController.php b/src/Controller/FrontpageController.php index 8f2b8fd..6f24b5c 100644 --- a/src/Controller/FrontpageController.php +++ b/src/Controller/FrontpageController.php @@ -50,24 +50,15 @@ class FrontpageController extends AbstractController ], ]; - private const COMING_SOON = [ - 'Deling af tools', - 'Deling af skills', - 'Ratings', - 'API', - 'Abonnér på ændringer', - 'Testcases', - ]; - /** * Render the placeholder frontpage. * * Anonymous visitors to `/` receive a design-preview landing page * that mirrors the AI Bibliotek prototype. Hero, search prompt, - * sample-assistant rail, "Sådan virker det" steps, and "Kommer - * snart" chips are rendered with hardcoded sample data — the - * point is to convey what the catalogue will feel like before - * the persistence and search layers land. + * sample-assistant rail, and "Sådan virker det" steps are rendered + * with hardcoded sample data — the point is to convey what the + * catalogue will feel like before the persistence and search + * layers land. * * @return Response the rendered `frontpage/index.html.twig` template */ @@ -84,7 +75,6 @@ public function index(): Response 'kommuner' => count($kommuner), 'models' => count($models), ], - 'coming_soon' => self::COMING_SOON, ]); } } diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index 00b00eb..7f36f2d 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -49,14 +49,6 @@ - - - {% for label in coming_soon %} - - {% endfor %} - - - {% if app.environment == 'dev' %}{% endif %} {% endblock %} From 952b2e659269ad079a1ff12f9e7f952f62ee3c18 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 13:20:16 +0200 Subject: [PATCH 06/24] docs: add project Twig component conventions to CLAUDE.md Documents the anonymous-component-first structure under templates/components/, the {% props %} + named-slot contract, the dev-mode {{ _self }} begin/end markers, and when to extract vs. inline. Intended as the reference future contributors (and future Claude sessions) read before adding new templates. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..196900d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# Project conventions for Claude + +This file documents project-specific patterns. Global preferences live in +`~/.claude/CLAUDE.md` and still apply. + +## Twig templates + +### Component-first + +All non-trivial markup belongs in a component under `templates/components/`, +not inline in page or layout templates. Page templates (`templates//…`) +should read as a thin composition of `` calls. + +We use **anonymous components** from `symfony/ux-twig-component` — no PHP +backing class. The component template alone defines the contract via +`{% props … %}`. + +### Directory layout + +Group components by domain under `templates/components/`. Nested namespaces +become nested directories: + +``` +templates/components/ + Eyebrow.html.twig # + Hero.html.twig # + Layout/SiteHeader.html.twig # + Nav/Link.html.twig # + Stats/List.html.twig # + Stats/Item.html.twig # +``` + +Use PascalCase for component file names (matches the `` casing). +Singular vs plural follows whether the component is a container (`List`) +or a single item (`Item`). + +### Component anatomy + +Every component template has this shape: + +```twig +{% if app.environment == 'dev' %}{% endif %} +{% props label, value, href = '#' %} + + {{ label }} + {{ value }} + {% block content %}{% endblock %} + +{% if app.environment == 'dev' %}{% endif %} +``` + +Rules: + +1. **Always add the dev markers** — one at the very top, one at the very + end. They emit `` and + `` in dev only, so the rendered HTML + in DevTools tells you which template produced any region. The path comes + from `{{ _self }}` automatically — never hardcode it. +2. **Declare props** with `{% props … %}` right after the opening marker. + Required props have no default; optional props use `prop = 'value'`. +3. **Default slot** is `{% block content %}{% endblock %}` — content placed + between `` lands here. +4. **Named slots** use `{% block %}{% endblock %}` and are filled + from the call site with ``. + Prefer named slots over `|raw` string props whenever the value contains + HTML (e.g. an inline ``). +5. Keep Tailwind utility classes inline. Design tokens (`text-ink`, + `bg-surface`, `font-display`, …) come from the `@theme` block in + `assets/styles/app.css`; do not introduce new CSS files for one-off + styles. + +### Templates that `extends` a layout + +Twig forbids any content outside `{% block %}` in an extending template. +Put the dev markers **inside** an existing block (typically `body`): + +```twig +{% extends 'base.html.twig' %} + +{% block body %} + {% if app.environment == 'dev' %}{% endif %} +
    + … +
    + {% if app.environment == 'dev' %}{% endif %} +{% endblock %} +``` + +### Calling components + +```twig + + + Et fælles bibliotek over kommunale AI-assistenter. + + + + + + + + +``` + +Self-close (``) when the component has no slot content. + +### When not to extract a component + +- A piece of markup used exactly once and unlikely to repeat. Inline it. +- A wrapper that adds no parameters and no semantics. The call site is + clearer with the underlying utilities. + +A component earns its place when it has a name, a contract (props / +slots), and at least one reason to be reused or replaced in isolation. + +### Stimulus + CSS hooks + +When a component carries a `data-controller=`, `data-action=`, +`data-…-target=`, or a hand-rolled CSS class hook (e.g. `.nav-toggle`, +`.nav-toggle-bar`), leave a short `{# … #}` comment in the component +explaining why the attribute must stay verbatim — these are load-bearing +for JS controllers in `assets/controllers/` or CSS rules in +`assets/styles/app.css`. From 162e5543f45160f5bc450c5d4d332c1a0fe50f26 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 9 Jun 2026 13:53:19 +0200 Subject: [PATCH 07/24] Added twig node visitor --- CLAUDE.md | 47 +++---- assets/controllers/hello_controller.js | 17 --- src/Twig/DevTemplateMarkerExtension.php | 44 ++++++ src/Twig/DevTemplateMarkerNodeVisitor.php | 126 ++++++++++++++++++ templates/base.html.twig | 4 +- templates/components/Box.html.twig | 2 - templates/components/CardRail/Card.html.twig | 2 - .../components/CardRail/Container.html.twig | 2 - templates/components/Eyebrow.html.twig | 2 - templates/components/Hero.html.twig | 2 - templates/components/Layout/Brand.html.twig | 2 - .../components/Layout/SiteFooter.html.twig | 2 - .../components/Layout/SiteHeader.html.twig | 2 - .../components/Layout/SkipLink.html.twig | 2 - templates/components/Nav/Link.html.twig | 2 - templates/components/Nav/Menu.html.twig | 2 - templates/components/Nav/Toggle.html.twig | 2 - templates/components/SearchBox.html.twig | 2 - templates/components/Stats/Item.html.twig | 2 - templates/components/Stats/List.html.twig | 2 - templates/components/StepList/Item.html.twig | 2 - templates/components/StepList/List.html.twig | 2 - templates/components/TagList/List.html.twig | 2 - templates/components/TagList/Tag.html.twig | 2 - templates/frontpage/index.html.twig | 2 - 25 files changed, 193 insertions(+), 85 deletions(-) delete mode 100644 assets/controllers/hello_controller.js create mode 100644 src/Twig/DevTemplateMarkerExtension.php create mode 100644 src/Twig/DevTemplateMarkerNodeVisitor.php diff --git a/CLAUDE.md b/CLAUDE.md index 196900d..65da4e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,53 +39,50 @@ or a single item (`Item`). Every component template has this shape: ```twig -{% if app.environment == 'dev' %}{% endif %} {% props label, value, href = '#' %} {{ label }} {{ value }} {% block content %}{% endblock %} -{% if app.environment == 'dev' %}{% endif %} ``` Rules: -1. **Always add the dev markers** — one at the very top, one at the very - end. They emit `` and - `` in dev only, so the rendered HTML - in DevTools tells you which template produced any region. The path comes - from `{{ _self }}` automatically — never hardcode it. -2. **Declare props** with `{% props … %}` right after the opening marker. - Required props have no default; optional props use `prop = 'value'`. -3. **Default slot** is `{% block content %}{% endblock %}` — content placed +1. **Declare props** with `{% props … %}` on the first line. Required + props have no default; optional props use `prop = 'value'`. +2. **Default slot** is `{% block content %}{% endblock %}` — content placed between `` lands here. -4. **Named slots** use `{% block %}{% endblock %}` and are filled +3. **Named slots** use `{% block %}{% endblock %}` and are filled from the call site with ``. Prefer named slots over `|raw` string props whenever the value contains HTML (e.g. an inline ``). -5. Keep Tailwind utility classes inline. Design tokens (`text-ink`, +4. Keep Tailwind utility classes inline. Design tokens (`text-ink`, `bg-surface`, `font-display`, …) come from the `@theme` block in `assets/styles/app.css`; do not introduce new CSS files for one-off styles. -### Templates that `extends` a layout +### Dev-mode template markers -Twig forbids any content outside `{% block %}` in an extending template. -Put the dev markers **inside** an existing block (typically `body`): +In the `dev` environment, every project template is wrapped at compile +time with HTML comments showing its path: -```twig -{% extends 'base.html.twig' %} - -{% block body %} - {% if app.environment == 'dev' %}{% endif %} -
    - … -
    - {% if app.environment == 'dev' %}{% endif %} -{% endblock %} +```html + +…component output… + ``` +This is automatic — do **not** add `{% if app.environment == 'dev' %}{% endif %}` +lines inside templates. The injection is done by +`App\Twig\DevTemplateMarkerNodeVisitor` (in `src/Twig/`), registered +only in dev so prod output is unchanged. + +For templates that `extends` another, the visitor wraps the content of +the `body` block specifically (the template's top-level content never +renders in that case). Page templates should therefore put their content +inside `{% block body %}…{% endblock %}` if they want the auto-marker. + ### Calling components ```twig diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js deleted file mode 100644 index 6fc936c..0000000 --- a/assets/controllers/hello_controller.js +++ /dev/null @@ -1,17 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -/* - * This is an example Stimulus controller! - * - * Any element with a data-controller="hello" attribute will cause - * this controller to be executed. The name "hello" comes from the filename: - * hello_controller.js -> "hello" - * - * Delete this file or adapt it for your use! - */ -export default class extends Controller { - connect() { - this.element.textContent = - "Hello Stimulus! Edit me in assets/controllers/hello_controller.js"; - } -} diff --git a/src/Twig/DevTemplateMarkerExtension.php b/src/Twig/DevTemplateMarkerExtension.php new file mode 100644 index 0000000..419e197 --- /dev/null +++ b/src/Twig/DevTemplateMarkerExtension.php @@ -0,0 +1,44 @@ + the visitors + * to register + */ + public function getNodeVisitors(): array + { + if ('dev' !== $this->environment) { + return []; + } + + return [new DevTemplateMarkerNodeVisitor()]; + } +} diff --git a/src/Twig/DevTemplateMarkerNodeVisitor.php b/src/Twig/DevTemplateMarkerNodeVisitor.php new file mode 100644 index 0000000..4d8bfae --- /dev/null +++ b/src/Twig/DevTemplateMarkerNodeVisitor.php @@ -0,0 +1,126 @@ +` / `` around each template + * boundary. Templates that `extends` another are wrapped at their + * `body` block instead, because their top-level body never runs at + * render time. + * + * Registered only in the dev environment by + * {@see DevTemplateMarkerExtension::getNodeVisitors()}; the visitor + * itself trusts the registration and unconditionally wraps every + * non-namespaced template it sees. + */ +final class DevTemplateMarkerNodeVisitor implements NodeVisitorInterface +{ + public function enterNode(Node $node, Environment $env): Node + { + return $node; + } + + /** + * Wrap the template body (or its `body` block, for extending + * templates) with begin and end marker TextNodes. + * + * @param Node $node the node being left + * @param Environment $env the Twig environment (unused) + * + * @return Node the original node, mutated in place when applicable + */ + public function leaveNode(Node $node, Environment $env): Node + { + if (!$node instanceof ModuleNode) { + return $node; + } + + $name = $node->getTemplateName(); + if (null === $name || str_starts_with($name, '@')) { + // Skip framework / vendor templates loaded via a Twig namespace. + return $node; + } + + $line = $node->getTemplateLine(); + $prefix = new TextNode('', $line); + $suffix = new TextNode('', $line); + + if ($node->hasNode('parent')) { + $this->wrapExtendingBody($node, $prefix, $suffix); + + return $node; + } + + $this->wrap($node, $prefix, $suffix); + + return $node; + } + + /** + * For templates that `extends` another, wrap the content of the + * `body` block — the template's top-level body never runs. + * + * Twig stores each block as a `BodyNode` containing the + * `BlockNode`; we iterate to find the BlockNode and replace its + * body with the wrapped sequence. + * + * @param ModuleNode $node the extending module + * @param TextNode $prefix the opening marker + * @param TextNode $suffix the closing marker + */ + private function wrapExtendingBody(ModuleNode $node, TextNode $prefix, TextNode $suffix): void + { + if (!$node->hasNode('blocks')) { + return; + } + $blocks = $node->getNode('blocks'); + if (!$blocks->hasNode('body')) { + return; + } + foreach ($blocks->getNode('body') as $child) { + if ($child instanceof BlockNode) { + $this->wrap($child, $prefix, $suffix); + + return; + } + } + } + + /** + * Lowest priority — run after other visitors so the markers stay + * the outermost layer of the compiled body. + * + * @return int the visitor priority + */ + public function getPriority(): int + { + return 0; + } + + /** + * Replace `$node`'s `body` child with a sequence: prefix, original + * body, suffix. + * + * @param Node $node the node whose body to wrap + * @param TextNode $prefix the opening marker + * @param TextNode $suffix the closing marker + */ + private function wrap(Node $node, TextNode $prefix, TextNode $suffix): void + { + $body = $node->getNode('body'); + $wrapped = new Node([$prefix, $body, $suffix], [], $body->getTemplateLine()); + $node->setNode('body', $wrapped); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 24a6be9..1469cf8 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,5 +1,4 @@ -{% if app.environment == 'dev' %} -{% endif %} + @@ -43,4 +42,3 @@ -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Box.html.twig b/templates/components/Box.html.twig index 2fc7011..abd66ee 100644 --- a/templates/components/Box.html.twig +++ b/templates/components/Box.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props eyebrow, lead = null %}
    {{ eyebrow }} @@ -7,4 +6,3 @@ {% endif %} {% block content %}{% endblock %}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/CardRail/Card.html.twig b/templates/components/CardRail/Card.html.twig index 1373f6f..f9bac1d 100644 --- a/templates/components/CardRail/Card.html.twig +++ b/templates/components/CardRail/Card.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props kommune, model, name, summary, href = '#' %} {{ name }} {{ summary }} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig index bd8ad3e..ab0e8c5 100644 --- a/templates/components/CardRail/Container.html.twig +++ b/templates/components/CardRail/Container.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props eyebrow, linkHref = '#', linkLabel, listLabel = 'Eksempler på assistenter' %}
    @@ -12,4 +11,3 @@ {% block content %}{% endblock %}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Eyebrow.html.twig b/templates/components/Eyebrow.html.twig index 2882e7e..802246a 100644 --- a/templates/components/Eyebrow.html.twig +++ b/templates/components/Eyebrow.html.twig @@ -1,4 +1,2 @@ -{% if app.environment == 'dev' %}{% endif %} {% props class = '' %}

    {% block content %}{% endblock %}

    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Hero.html.twig b/templates/components/Hero.html.twig index e67303c..eb28487 100644 --- a/templates/components/Hero.html.twig +++ b/templates/components/Hero.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props eyebrow %}
    {{ eyebrow|raw }} @@ -10,4 +9,3 @@

    {% block content %}{% endblock %}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/Brand.html.twig b/templates/components/Layout/Brand.html.twig index 92c66c7..18e89b0 100644 --- a/templates/components/Layout/Brand.html.twig +++ b/templates/components/Layout/Brand.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props href, initials = 'AI', name = 'AI Bibliotek', tagline = 'del & hjemtag assistenter · prototype' %} @@ -7,4 +6,3 @@ {{ tagline|raw }} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/SiteFooter.html.twig b/templates/components/Layout/SiteFooter.html.twig index b91fee1..6109857 100644 --- a/templates/components/Layout/SiteFooter.html.twig +++ b/templates/components/Layout/SiteFooter.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props linkHref, linkLabel %} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/SiteHeader.html.twig b/templates/components/Layout/SiteHeader.html.twig index 83d6336..2189fc3 100644 --- a/templates/components/Layout/SiteHeader.html.twig +++ b/templates/components/Layout/SiteHeader.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {# Header shell. Default slot receives the nav menu. #}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Layout/SkipLink.html.twig b/templates/components/Layout/SkipLink.html.twig index bab4187..0558afd 100644 --- a/templates/components/Layout/SkipLink.html.twig +++ b/templates/components/Layout/SkipLink.html.twig @@ -1,6 +1,4 @@ -{% if app.environment == 'dev' %}{% endif %} {% props href = '#main', label = 'Spring til indhold' %} {{ label }} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Nav/Link.html.twig b/templates/components/Nav/Link.html.twig index 5de7459..409fb22 100644 --- a/templates/components/Nav/Link.html.twig +++ b/templates/components/Nav/Link.html.twig @@ -1,4 +1,2 @@ -{% if app.environment == 'dev' %}{% endif %} {% props href %} {% block content %}{% endblock %} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Nav/Menu.html.twig b/templates/components/Nav/Menu.html.twig index 0cc0ce7..8f7ebcb 100644 --- a/templates/components/Nav/Menu.html.twig +++ b/templates/components/Nav/Menu.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props id = 'primary-nav', label = 'Hovedmenu' %} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Nav/Toggle.html.twig b/templates/components/Nav/Toggle.html.twig index 3fb461f..cdea2a2 100644 --- a/templates/components/Nav/Toggle.html.twig +++ b/templates/components/Nav/Toggle.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {# Hamburger button. Class/ARIA/data-action are referenced by app.css and nav_toggle_controller.js — preserve verbatim. #} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/SearchBox.html.twig b/templates/components/SearchBox.html.twig index 6fa0f1c..6611af8 100644 --- a/templates/components/SearchBox.html.twig +++ b/templates/components/SearchBox.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props legend = 'Find en assistent', placeholder, submitLabel = 'Søg', action = '#', name = 'q', inputId = 'frontpage-search' %}
    {{ legend }} @@ -20,4 +19,3 @@
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Stats/Item.html.twig b/templates/components/Stats/Item.html.twig index 653c9f2..3c682df 100644 --- a/templates/components/Stats/Item.html.twig +++ b/templates/components/Stats/Item.html.twig @@ -1,7 +1,5 @@ -{% if app.environment == 'dev' %}{% endif %} {% props label, value %}
    {{ label }}
    {{ value }}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/Stats/List.html.twig b/templates/components/Stats/List.html.twig index 7885265..e77e55c 100644 --- a/templates/components/Stats/List.html.twig +++ b/templates/components/Stats/List.html.twig @@ -1,5 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %}
    {% block content %}{% endblock %}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/StepList/Item.html.twig b/templates/components/StepList/Item.html.twig index b7f8358..e62b18c 100644 --- a/templates/components/StepList/Item.html.twig +++ b/templates/components/StepList/Item.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props index, lead, body %}
  • {{ index }} @@ -6,4 +5,3 @@ {{ lead }}{{ body }}
  • -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/StepList/List.html.twig b/templates/components/StepList/List.html.twig index 245469a..f1ea3cf 100644 --- a/templates/components/StepList/List.html.twig +++ b/templates/components/StepList/List.html.twig @@ -1,5 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %}
      {% block content %}{% endblock %}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/TagList/List.html.twig b/templates/components/TagList/List.html.twig index 2710384..7948ffa 100644 --- a/templates/components/TagList/List.html.twig +++ b/templates/components/TagList/List.html.twig @@ -1,6 +1,4 @@ -{% if app.environment == 'dev' %}{% endif %} {% props label %}
    {% block content %}{% endblock %}
    -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/components/TagList/Tag.html.twig b/templates/components/TagList/Tag.html.twig index 68cb0a4..f2683ac 100644 --- a/templates/components/TagList/Tag.html.twig +++ b/templates/components/TagList/Tag.html.twig @@ -1,4 +1,3 @@ -{% if app.environment == 'dev' %}{% endif %} {% props label, badge = 'snart', title = 'Kommer snart' %} {{ badge }} -{% if app.environment == 'dev' %}{% endif %} diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index 7f36f2d..953779d 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -3,7 +3,6 @@ {% block title %}AI Bibliotek – forhåndsvisning{% endblock %} {% block body %} - {% if app.environment == 'dev' %}{% endif %}
    @@ -50,5 +49,4 @@
    - {% if app.environment == 'dev' %}{% endif %} {% endblock %} From 6136d0755f2fd717125a0d62195d286d7eeb149e Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:16:18 +0200 Subject: [PATCH 08/24] feat: add Danish translations + brand env vars Install symfony/translation with default locale da and a single translations/messages.da.yaml file holding every user-facing label, ARIA string, placeholder, page title, and copy block. Components translate text props internally via |trans, so call sites pass keys (e.g. label="frontpage.stats.assistants") rather than literal Danish text. The hero heading and footer caption use _html-suffixed keys rendered with |raw. Brand identity (name, tagline, initials) is sourced from BRAND_* environment variables, exposed as Twig globals (brand_name, brand_tagline, brand_initials) with parameter-backed defaults in config/services.yaml so the page works whether or not .env defines them. Brand strings are configuration, not localization, and are not translated. CLAUDE.md documents the new Translations and Brand-identity sections. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 83 ++++++++++++++++ composer.json | 1 + composer.lock | 95 ++++++++++++++++++- config/packages/translation.yaml | 7 ++ config/packages/twig.yaml | 4 + config/reference.php | 2 +- config/services.yaml | 4 + symfony.lock | 13 +++ templates/base.html.twig | 16 ++-- templates/components/Box.html.twig | 4 +- .../components/CardRail/Container.html.twig | 8 +- templates/components/Hero.html.twig | 2 +- templates/components/Layout/Brand.html.twig | 4 +- .../components/Layout/SiteFooter.html.twig | 2 +- .../components/Layout/SkipLink.html.twig | 4 +- templates/components/Nav/Menu.html.twig | 4 +- templates/components/Nav/Toggle.html.twig | 2 +- templates/components/SearchBox.html.twig | 14 +-- templates/components/Stats/Item.html.twig | 2 +- templates/components/StepList/Item.html.twig | 2 +- templates/components/TagList/List.html.twig | 2 +- templates/components/TagList/Tag.html.twig | 8 +- templates/frontpage/index.html.twig | 34 +++---- translations/.gitignore | 0 translations/messages.da.yaml | 63 ++++++++++++ 25 files changed, 321 insertions(+), 59 deletions(-) create mode 100644 config/packages/translation.yaml create mode 100644 translations/.gitignore create mode 100644 translations/messages.da.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 65da4e3..35feda8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,89 @@ Self-close (``) when the component has no slot content. A component earns its place when it has a name, a contract (props / slots), and at least one reason to be reused or replaced in isolation. +### Translations + +The default locale is `da`. All user-facing strings (labels, buttons, +ARIA labels, placeholders, page titles, copy) live in a single file: + +``` +translations/messages.da.yaml +``` + +Keys are hierarchical dot-notation, grouped by area (`nav.*`, `frontpage.*`, +`search.*`, `layout.*`, …). Conventions: + +1. **Components translate text props internally.** Each text-bearing + prop defaults to a translation key, and the component applies `|trans` + itself. Call sites pass keys, not Danish strings: + + ```twig + {# Stats/Item.html.twig — component #} + {% props label, value %} +
    +
    {{ label|trans }}
    +
    {{ value }}
    +
    + + {# Call site #} + + ``` + + Defaults that hold a translation key let a call site omit the prop: + + ```twig + {% props label = 'nav.menu_label' %} + + ``` + +2. **HTML in translations.** Keys whose value contains HTML get the + `_html` suffix and are rendered with `|trans|raw` at the call site + (the translation source is trusted YAML, no XSS risk): + + ```yaml + frontpage: + hero: + heading_html: 'Et fælles bibliotek over kommunale AI-assistenter.' + ``` + +3. **Placeholders use `%name%` syntax** and are filled at call time: + + ```yaml + frontpage: + title: '%brand% – forhåndsvisning' + ``` + ```twig + {{ 'frontpage.title'|trans({'%brand%': brand_name}) }} + ``` + +4. **`SAMPLE_ASSISTANTS` and other placeholder content data is NOT + translated** — only chrome and copy strings are. Sample data will be + replaced by real persistence soon and isn't worth extracting. + +5. `bin/console debug:translation da` reports keys passed as props as + `unused` because the static scanner can't see dynamic |trans calls + inside components. Verify usage by inspecting the rendered HTML + instead. + +### Brand identity (env-driven, not translated) + +The brand name, tagline, and logo initials come from environment +variables, exposed as Twig globals: + +| Env var | Twig global | Default (in `config/services.yaml`) | +|---|---|---| +| `BRAND_NAME` | `brand_name` | `AI Bibliotek` | +| `BRAND_TAGLINE` | `brand_tagline` | `del & hjemtag assistenter · prototype` | +| `BRAND_INITIALS` | `brand_initials` | `AI` | + +Wiring lives in `config/packages/twig.yaml` (globals block, with +`default:` env processor) and `config/services.yaml` (parameter +defaults). Set the env vars in `.env` for committed defaults, or in +`.env.local` for per-machine overrides. + +Use `{{ brand_name }}` directly in templates; do not look it up via +`|trans`. Brand identity is configuration, not localization. + ### Stimulus + CSS hooks When a component carries a `data-controller=`, `data-action=`, diff --git a/composer.json b/composer.json index ef0bb2f..20824fc 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "symfony/framework-bundle": "~8.1.0", "symfony/runtime": "~8.1.0", "symfony/stimulus-bundle": "^3.1", + "symfony/translation": "8.1.*", "symfony/twig-bundle": "~8.1.0", "symfony/ux-twig-component": "^3.1", "symfony/yaml": "~8.1.0", diff --git a/composer.lock b/composer.lock index 50094e0..f6cc47e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ae83a4ba287dd9b635c1649586e81003", + "content-hash": "e4e48becb9e85875ada8fcc6f704e2dc", "packages": [ { "name": "composer/semver", @@ -3058,6 +3058,99 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.7.0", diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..40921c7 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,7 @@ +framework: + default_locale: da + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - da + providers: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d9..c9cc527 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,9 @@ twig: file_name_pattern: '*.twig' + globals: + brand_name: '%env(default:brand.default_name:BRAND_NAME)%' + brand_tagline: '%env(default:brand.default_tagline:BRAND_TAGLINE)%' + brand_initials: '%env(default:brand.default_initials:BRAND_INITIALS)%' when@test: twig: diff --git a/config/reference.php b/config/reference.php index aeba6ad..f852f60 100644 --- a/config/reference.php +++ b/config/reference.php @@ -305,7 +305,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * fallbacks?: string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" diff --git a/config/services.yaml b/config/services.yaml index 7adddb9..7265e7b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,10 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + # Brand identity defaults — overridden by BRAND_* environment variables. + brand.default_name: 'AI Bibliotek' + brand.default_tagline: 'del & hjemtag assistenter · prototype' + brand.default_initials: 'AI' services: # default configuration for services in *this* file diff --git a/symfony.lock b/symfony.lock index 572dcb1..cc5a996 100644 --- a/symfony.lock +++ b/symfony.lock @@ -111,6 +111,19 @@ "assets/stimulus_bootstrap.js" ] }, + "symfony/translation": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, "symfony/twig-bundle": { "version": "8.1", "recipe": { diff --git a/templates/base.html.twig b/templates/base.html.twig index 1469cf8..a9a0c46 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -3,7 +3,7 @@ - {% block title %}AI Bibliotek{% endblock %} + {% block title %}{{ brand_name }}{% endblock %} @@ -23,11 +23,11 @@ - Katalog - Del assistent - Mine assistenter - Favoritter - Samlinger + {{ 'nav.links.catalog'|trans }} + {{ 'nav.links.share'|trans }} + {{ 'nav.links.mine'|trans }} + {{ 'nav.links.favorites'|trans }} + {{ 'nav.links.collections'|trans }} @@ -37,8 +37,8 @@ {% block body %}{% endblock %}
    - - Forhåndsvisning · ingen data endnu · designet følger prototype-mockuppet + + {{ 'layout.footer.caption_html'|trans({'%link%': 'https://itk-dev.github.io/research-projects/projects/ai-bibliotek/mocks/index.html#/'})|raw }} diff --git a/templates/components/Box.html.twig b/templates/components/Box.html.twig index abd66ee..0265ba8 100644 --- a/templates/components/Box.html.twig +++ b/templates/components/Box.html.twig @@ -1,8 +1,8 @@ {% props eyebrow, lead = null %}
    - {{ eyebrow }} + {{ eyebrow|trans }} {% if lead %} -

    {{ lead }}

    +

    {{ lead|trans }}

    {% endif %} {% block content %}{% endblock %}
    diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig index ab0e8c5..177f68a 100644 --- a/templates/components/CardRail/Container.html.twig +++ b/templates/components/CardRail/Container.html.twig @@ -1,13 +1,13 @@ -{% props eyebrow, linkHref = '#', linkLabel, listLabel = 'Eksempler på assistenter' %} +{% props eyebrow, linkHref = '#', linkLabel, listLabel = 'frontpage.rail.list_label' %}
    - {{ eyebrow }} + {{ eyebrow|trans }} {{ linkLabel }} + href="{{ linkHref }}">{{ linkLabel|trans }}
    + aria-label="{{ listLabel|trans }}"> {% block content %}{% endblock %}
    diff --git a/templates/components/Hero.html.twig b/templates/components/Hero.html.twig index eb28487..1b76e8e 100644 --- a/templates/components/Hero.html.twig +++ b/templates/components/Hero.html.twig @@ -1,6 +1,6 @@ {% props eyebrow %}
    - {{ eyebrow|raw }} + {{ eyebrow|trans }}

    {% block heading %}{% endblock %}

    diff --git a/templates/components/Layout/Brand.html.twig b/templates/components/Layout/Brand.html.twig index 18e89b0..9271659 100644 --- a/templates/components/Layout/Brand.html.twig +++ b/templates/components/Layout/Brand.html.twig @@ -1,5 +1,5 @@ -{% props href, initials = 'AI', name = 'AI Bibliotek', tagline = 'del & hjemtag assistenter · prototype' %} - +{% props href, initials = brand_initials, name = brand_name, tagline = brand_tagline %} + {{ name }} diff --git a/templates/components/Layout/SiteFooter.html.twig b/templates/components/Layout/SiteFooter.html.twig index 6109857..0338dfb 100644 --- a/templates/components/Layout/SiteFooter.html.twig +++ b/templates/components/Layout/SiteFooter.html.twig @@ -2,6 +2,6 @@ diff --git a/templates/components/Layout/SkipLink.html.twig b/templates/components/Layout/SkipLink.html.twig index 0558afd..cbc6f1a 100644 --- a/templates/components/Layout/SkipLink.html.twig +++ b/templates/components/Layout/SkipLink.html.twig @@ -1,4 +1,4 @@ -{% props href = '#main', label = 'Spring til indhold' %} +{% props href = '#main', label = 'layout.skip_link' %} - {{ label }} + {{ label|trans }} diff --git a/templates/components/Nav/Menu.html.twig b/templates/components/Nav/Menu.html.twig index 8f7ebcb..d5db1ac 100644 --- a/templates/components/Nav/Menu.html.twig +++ b/templates/components/Nav/Menu.html.twig @@ -1,7 +1,7 @@ -{% props id = 'primary-nav', label = 'Hovedmenu' %} +{% props id = 'primary-nav', label = 'nav.menu_label' %} diff --git a/templates/components/Nav/Toggle.html.twig b/templates/components/Nav/Toggle.html.twig index cdea2a2..99fc863 100644 --- a/templates/components/Nav/Toggle.html.twig +++ b/templates/components/Nav/Toggle.html.twig @@ -3,7 +3,7 @@ type="button" aria-expanded="false" aria-controls="primary-nav" - aria-label="Vis menu" + aria-label="{{ 'nav.toggle_label'|trans }}" data-action="click->nav-toggle#toggle"> diff --git a/templates/components/SearchBox.html.twig b/templates/components/SearchBox.html.twig index 6611af8..d0502f4 100644 --- a/templates/components/SearchBox.html.twig +++ b/templates/components/SearchBox.html.twig @@ -1,21 +1,21 @@ -{% props legend = 'Find en assistent', placeholder, submitLabel = 'Søg', action = '#', name = 'q', inputId = 'frontpage-search' %} -
    - {{ legend }} +{% props legend = 'search.legend', placeholder, submitLabel = 'search.submit', action = '#', name = 'q', inputId = 'frontpage-search' %} +
    + {{ legend|trans }}
    diff --git a/templates/components/Stats/Item.html.twig b/templates/components/Stats/Item.html.twig index 3c682df..159e82d 100644 --- a/templates/components/Stats/Item.html.twig +++ b/templates/components/Stats/Item.html.twig @@ -1,5 +1,5 @@ {% props label, value %}
    -
    {{ label }}
    +
    {{ label|trans }}
    {{ value }}
    diff --git a/templates/components/StepList/Item.html.twig b/templates/components/StepList/Item.html.twig index e62b18c..9ec34c5 100644 --- a/templates/components/StepList/Item.html.twig +++ b/templates/components/StepList/Item.html.twig @@ -2,6 +2,6 @@
  • {{ index }} - {{ lead }}{{ body }} + {{ lead|trans }}{{ body|trans }}
  • diff --git a/templates/components/TagList/List.html.twig b/templates/components/TagList/List.html.twig index 7948ffa..e67c80f 100644 --- a/templates/components/TagList/List.html.twig +++ b/templates/components/TagList/List.html.twig @@ -1,4 +1,4 @@ {% props label %} -
    +
    {% block content %}{% endblock %}
    diff --git a/templates/components/TagList/Tag.html.twig b/templates/components/TagList/Tag.html.twig index f2683ac..e2f96a8 100644 --- a/templates/components/TagList/Tag.html.twig +++ b/templates/components/TagList/Tag.html.twig @@ -1,7 +1,7 @@ -{% props label, badge = 'snart', title = 'Kommer snart' %} +{% props label, badge = 'tag.badge_default', title = 'tag.title_default' %} - {{ label }} - {{ badge }} + title="{{ title|trans }}"> + {{ label|trans }} + {{ badge|trans }} diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index 953779d..ac244e8 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -1,30 +1,24 @@ {% extends 'base.html.twig' %} -{% block title %}AI Bibliotek – forhåndsvisning{% endblock %} +{% block title %}{{ 'frontpage.title'|trans({'%brand%': brand_name}) }}{% endblock %} {% block body %}
    - - - Et fælles bibliotek over kommunale AI-assistenter. - - - Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser - en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den - lokalt — så gode løsninger skalerer nationalt. - + + {{ 'frontpage.hero.heading_html'|trans|raw }} + {{ 'frontpage.hero.lead'|trans }} - - - + + + - + - + {% for a in assistants %} - + {% set steps = [ - {lead: 'Opret bruger', body: ' og log ind som repræsentant for din myndighed.'}, - {lead: 'Find en assistent', body: ' – søg og filtrér på kommune, sprogmodel og datafølsomhed.'}, - {lead: 'Hjemtag', body: ' – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere.'}, - {lead: 'Tilpas lokalt', body: ' – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug.'}, + {lead: 'frontpage.steps.1.lead', body: 'frontpage.steps.1.body'}, + {lead: 'frontpage.steps.2.lead', body: 'frontpage.steps.2.body'}, + {lead: 'frontpage.steps.3.lead', body: 'frontpage.steps.3.body'}, + {lead: 'frontpage.steps.4.lead', body: 'frontpage.steps.4.body'}, ] %} {% for step in steps %} diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml new file mode 100644 index 0000000..24b7dc7 --- /dev/null +++ b/translations/messages.da.yaml @@ -0,0 +1,63 @@ +# Danish UI strings. Hierarchical dot-notation keys. +# +# - Brand identity (name, tagline, initials) is NOT here — see BRAND_* +# env vars in .env and the Twig globals in config/packages/twig.yaml. +# - Keys ending in `_html` carry HTML and must be rendered with |raw at +# the call site. +# - Components apply |trans on text props themselves; call sites pass +# keys, not literal strings. + +layout: + skip_link: 'Spring til indhold' + brand_aria_label: 'Til forsiden' + footer: + caption_html: 'Forhåndsvisning · ingen data endnu · designet følger prototype-mockuppet' + about_link: 'Om projektet' + +nav: + menu_label: 'Hovedmenu' + toggle_label: 'Vis menu' + links: + catalog: 'Katalog' + share: 'Del assistent' + mine: 'Mine assistenter' + favorites: 'Favoritter' + collections: 'Samlinger' + +search: + legend: 'Find en assistent' + submit: 'Søg' + placeholder: 'Søg efter borgerservice, referat, journalisering…' + +tag: + badge_default: 'snart' + title_default: 'Kommer snart' + +frontpage: + title: '%brand% – forhåndsvisning' + hero: + eyebrow: 'Del & hjemtag · dansk offentlig AI' + heading_html: 'Et fælles bibliotek over kommunale AI-assistenter.' + lead: 'Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt.' + stats: + assistants: 'Assistenter' + kommuner: 'Kommuner' + models: 'Sprogmodeller' + rail: + eyebrow: 'Forhåndsvisning' + link: 'Kataloget kommer →' + list_label: 'Eksempler på assistenter' + steps: + eyebrow: 'Sådan virker det' + '1': + lead: 'Opret bruger' + body: ' og log ind som repræsentant for din myndighed.' + '2': + lead: 'Find en assistent' + body: ' – søg og filtrér på kommune, sprogmodel og datafølsomhed.' + '3': + lead: 'Hjemtag' + body: ' – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere.' + '4': + lead: 'Tilpas lokalt' + body: ' – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug.' From c4cca3c0f5d1a773ff56d64904b65bf9d300b5c5 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:33:57 +0200 Subject: [PATCH 09/24] fix: card hover top border + stats stacking on narrow viewports CardRail container: add pt-2 so the card's -translate-y-0.5 lift and its shadow-md aren't clipped by overflow-y:auto (which the spec forces on us via overflow-x:auto). Stats list: switch grid-cols-3 to grid-cols-1 sm:grid-cols-3 so the long single-word Danish labels (SPROGMODELLER) get their own row below the sm breakpoint instead of overflowing the column and pushing the page horizontally. Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/components/CardRail/Container.html.twig | 5 ++++- templates/components/Stats/List.html.twig | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig index 177f68a..0e9ba19 100644 --- a/templates/components/CardRail/Container.html.twig +++ b/templates/components/CardRail/Container.html.twig @@ -5,7 +5,10 @@ {{ linkLabel|trans }}
    -
    {% block content %}{% endblock %} diff --git a/templates/components/Stats/List.html.twig b/templates/components/Stats/List.html.twig index e77e55c..9cf57df 100644 --- a/templates/components/Stats/List.html.twig +++ b/templates/components/Stats/List.html.twig @@ -1,3 +1,3 @@ -
    +
    {% block content %}{% endblock %}
    From de1696b6f12ec031f5b644ce784d151d0e94676b Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:34:03 +0200 Subject: [PATCH 10/24] chore: ship brand identity defaults in .env Adds BRAND_NAME, BRAND_TAGLINE, BRAND_INITIALS as committed defaults so the parameter fallbacks in config/services.yaml are only a safety net. .env.local can override per-deployment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env b/.env index 4d598cc..cfa3bdc 100644 --- a/.env +++ b/.env @@ -28,3 +28,9 @@ APP_SHARE_DIR=var/share # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=http://localhost ###< symfony/routing ### + +###> brand identity ### +BRAND_NAME="AI Bibliotek" +BRAND_TAGLINE="del & hjemtag assistenter · prototype" +BRAND_INITIALS="AI" +###< brand identity ### From 172cf78a7edce09ee4b6cffabe0a2a5ae4f587b7 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:34:10 +0200 Subject: [PATCH 11/24] docs: note Tailwind rebuild gotcha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no live watcher and cache:clear does not rebuild the stylesheet — new utility classes only apply after tailwind:build runs. CLAUDE.md gets a dedicated subsection so future sessions stop being puzzled; README.md gets a heads-up callout next to the build commands. Also fixes a stale hello_controller.js reference in README. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 23 +++++++++++++++++++++++ README.md | 8 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35feda8..965aa77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,29 @@ defaults). Set the env vars in `.env` for committed defaults, or in Use `{{ brand_name }}` directly in templates; do not look it up via `|trans`. Brand identity is configuration, not localization. +### Rebuilding Tailwind after edits + +The project compiles Tailwind via `symfonycasts/tailwind-bundle`. There is +**no live watcher** by default — `cache:clear` does not rebuild CSS, and +new utility classes added to templates will not apply until Tailwind +recompiles `var/tailwind/app.built.css`. + +After editing templates or component classes (especially when adding a +utility that wasn't already in use, e.g. `pt-2`, `grid-cols-1`), run: + +```sh +docker compose exec phpfpm bin/console tailwind:build +``` + +For active styling work, keep a watcher open in a side terminal: + +```sh +docker compose exec phpfpm bin/console tailwind:build --watch +``` + +AssetMapper picks up the rebuilt file on the next request — no separate +asset-map step needed in dev. + ### Stimulus + CSS hooks When a component carries a `data-controller=`, `data-action=`, diff --git a/README.md b/README.md index ff5a224..c95af7f 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,18 @@ itkdev-docker-compose php bin/console asset-map:compile itkdev-docker-compose php bin/console debug:asset-map ``` +> **Heads-up:** there is no live Tailwind watcher running by default, and +> `cache:clear` does **not** rebuild the stylesheet. After editing a +> template that introduces a utility class not already in use (e.g. +> `pt-2`, `grid-cols-1`), run `tailwind:build` — or keep a +> `tailwind:build --watch` terminal open while you style. + Source files live under [`assets/`](assets): - `assets/app.js` — JavaScript entrypoint, boots Stimulus. - `assets/styles/app.css` — Tailwind entrypoint (`@import "tailwindcss";`). - `assets/controllers/` — Stimulus controllers, auto-registered by - filename (`hello_controller.js` → `data-controller="hello"`). + filename (`nav_toggle_controller.js` → `data-controller="nav-toggle"`). ## References From 8e4a8bb7390ace663bb383b8e7400132e9a3284c Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:34:18 +0200 Subject: [PATCH 12/24] chore: stub footer links to # until destinations are decided Drops the hardcoded github.com/itk-dev/ai-lib and prototype-mockup URLs from base.html.twig so the placeholder frontpage doesn't ship pointers we haven't committed to yet. Both footer link href and the %link% substitution in the caption become '#' and can be wired to real targets (env var or route) later. Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/base.html.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/base.html.twig b/templates/base.html.twig index a9a0c46..ebc91c3 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -37,8 +37,8 @@ {% block body %}{% endblock %} - - {{ 'layout.footer.caption_html'|trans({'%link%': 'https://itk-dev.github.io/research-projects/projects/ai-bibliotek/mocks/index.html#/'})|raw }} + + {{ 'layout.footer.caption_html'|trans({'%link%': '#'})|raw }} From ea1e55a8eb95c90e65c472328d5a0f5b038bef16 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:50:50 +0200 Subject: [PATCH 13/24] refactor: cards wrap into rows instead of horizontal scroll The CardRail was a horizontal snap-scroll rail. Its flex children with min-w-[260px] forced the rail's min-content to ~1364px, which propagated up through the section into the view-root's implicit grid column and dragged the entire page horizontally on narrow viewports. Switching the container to 'flex flex-wrap gap-4' and the card to 'grow basis-[260px] max-w-[400px]' lets cards reflow onto new rows as the viewport narrows. Also adds grid-cols-1 to view-root so its implicit column gets minmax(0, 1fr) and no future overflow-x child triggers the same propagation. Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/components/CardRail/Card.html.twig | 4 +++- templates/components/CardRail/Container.html.twig | 7 +++---- templates/frontpage/index.html.twig | 5 ++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/templates/components/CardRail/Card.html.twig b/templates/components/CardRail/Card.html.twig index f9bac1d..b3f784e 100644 --- a/templates/components/CardRail/Card.html.twig +++ b/templates/components/CardRail/Card.html.twig @@ -1,5 +1,7 @@ {% props kommune, model, name, summary, href = '#' %} - {{ kommune }} · {{ model }} diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig index 0e9ba19..55967c9 100644 --- a/templates/components/CardRail/Container.html.twig +++ b/templates/components/CardRail/Container.html.twig @@ -5,10 +5,9 @@ {{ linkLabel|trans }}
    - {# pt-2: overflow-x:auto forces overflow-y:auto per CSS spec, which #} - {# clips the card's `-translate-y-0.5` hover lift. Top padding gives #} - {# the lifted card + its shadow-md room before the clipping edge. #} -
    {% block content %}{% endblock %} diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig index ac244e8..e67515d 100644 --- a/templates/frontpage/index.html.twig +++ b/templates/frontpage/index.html.twig @@ -3,7 +3,10 @@ {% block title %}{{ 'frontpage.title'|trans({'%brand%': brand_name}) }}{% endblock %} {% block body %} -
    + {# grid-cols-1 gives the implicit column min 0 (minmax(0, 1fr)), so #} + {# children like the CardRail can let their overflow-x-auto kick in #} + {# instead of expanding the grid to their min-content width. #} +
    {{ 'frontpage.hero.heading_html'|trans|raw }} From d9e621c864710fa0bd6e4985ef277658ca50d194 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Tue, 9 Jun 2026 14:51:40 +0200 Subject: [PATCH 14/24] refactor: promote section eyebrows to

    , card titles to

    MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eyebrow gains an 'as' prop defaulting to h2 — section kickers are now real subheadings under the hero's h1. Hero overrides with as="p" because its eyebrow sits above the h1 and isn't a heading. Card titles become h3, completing the h1 -> h2 -> h3 hierarchy. To keep the visual identical, .eyebrow declares font-family: var(--font-sans) (the @layer base h1..h4 rule would otherwise force the display serif onto a

    ), and the card title gets tracking-normal to cancel the -0.01em letter-spacing applied to every heading by that same base rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/styles/app.css | 4 ++++ templates/components/CardRail/Card.html.twig | 5 ++++- templates/components/Eyebrow.html.twig | 6 ++++-- templates/components/Hero.html.twig | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index 9543d24..49926fb 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -72,10 +72,14 @@ */ @layer components { /* Eyebrow label with leading decorative rule, used as a section kicker. */ + /* font-family is set explicitly so an

    renders in */ + /* the sans body font instead of the serif `--font-display` that the */ + /* @layer base { h1..h4 } rule above otherwise applies to headings. */ .eyebrow { display: inline-flex; align-items: center; gap: 0.5rem; + font-family: var(--font-sans); font-size: 0.78rem; font-weight: 600; letter-spacing: 0.16em; diff --git a/templates/components/CardRail/Card.html.twig b/templates/components/CardRail/Card.html.twig index b3f784e..d282dcb 100644 --- a/templates/components/CardRail/Card.html.twig +++ b/templates/components/CardRail/Card.html.twig @@ -5,6 +5,9 @@ href="{{ href }}" role="listitem"> {{ kommune }} · {{ model }} - {{ name }} + {# tracking-normal cancels the -0.01em letter-spacing that the #} + {# @layer base rule applies to every heading, so the visual matches #} + {# the previous . #} +

    {{ name }}

    {{ summary }} diff --git a/templates/components/Eyebrow.html.twig b/templates/components/Eyebrow.html.twig index 802246a..6f53b69 100644 --- a/templates/components/Eyebrow.html.twig +++ b/templates/components/Eyebrow.html.twig @@ -1,2 +1,4 @@ -{% props class = '' %} -

    {% block content %}{% endblock %}

    +{% props class = '', as = 'h2' %} +{# `as` lets a call site pick the tag — defaults to h2 because most #} +{# section kickers are the section's heading. Hero passes as="p". #} +<{{ as }} class="eyebrow{{ class ? ' ' ~ class : '' }}">{% block content %}{% endblock %} diff --git a/templates/components/Hero.html.twig b/templates/components/Hero.html.twig index 1b76e8e..95a5611 100644 --- a/templates/components/Hero.html.twig +++ b/templates/components/Hero.html.twig @@ -1,6 +1,6 @@ {% props eyebrow %}
    - {{ eyebrow|trans }} + {{ eyebrow|trans }}

    {% block heading %}{% endblock %}

    From 0c3f5c86ebec0db5d8deec18583525520e538ffb Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 10:29:26 +0200 Subject: [PATCH 15/24] ci: fix yaml-lint, composer-normalized, and phpunit checks Three failing PR checks addressed: - yaml-lint: translations/messages.da.yaml reformatted with Prettier. - composer-normalized: pin symfony/translation as ~8.1.0 instead of 8.1.* so composer normalize stops rewriting it on every CI run; composer.lock refreshed. - PHPUnit + coverage gate: assets/styles/app.css imports tailwindcss via Tailwind v4's directive, which AssetMapper can't resolve until bin/console tailwind:build has run. The Tests workflow now runs tailwind:build after composer install and before phpunit, mirroring the local-dev flow documented in README/CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/tests.yaml | 3 ++ composer.json | 2 +- composer.lock | 2 +- translations/messages.da.yaml | 74 +++++++++++++++++------------------ 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c55bd8e..7cce7b8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,6 +19,9 @@ jobs: - name: Install dependencies run: docker compose run --rm phpfpm composer install + - name: Build Tailwind CSS + run: docker compose run --rm phpfpm bin/console tailwind:build + - name: Run PHPUnit with coverage run: docker compose run -e XDEBUG_MODE=coverage --rm phpfpm vendor/bin/phpunit --coverage-clover=coverage/clover.xml diff --git a/composer.json b/composer.json index f65b91e..79beba5 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "symfony/framework-bundle": "~8.1.0", "symfony/runtime": "~8.1.0", "symfony/stimulus-bundle": "^3.1", - "symfony/translation": "8.1.*", + "symfony/translation": "~8.1.0", "symfony/twig-bundle": "~8.1.0", "symfony/ux-twig-component": "^3.1", "symfony/yaml": "~8.1.0", diff --git a/composer.lock b/composer.lock index ff5870c..abb6510 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "16d1c6db24b1f066b904fa3dd696d35c", + "content-hash": "5e5be02b1936d9ba94164fc47d576055", "packages": [ { "name": "composer/semver", diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 24b7dc7..d7804b2 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -8,56 +8,56 @@ # keys, not literal strings. layout: - skip_link: 'Spring til indhold' - brand_aria_label: 'Til forsiden' + skip_link: "Spring til indhold" + brand_aria_label: "Til forsiden" footer: caption_html: 'Forhåndsvisning · ingen data endnu · designet følger prototype-mockuppet' - about_link: 'Om projektet' + about_link: "Om projektet" nav: - menu_label: 'Hovedmenu' - toggle_label: 'Vis menu' + menu_label: "Hovedmenu" + toggle_label: "Vis menu" links: - catalog: 'Katalog' - share: 'Del assistent' - mine: 'Mine assistenter' - favorites: 'Favoritter' - collections: 'Samlinger' + catalog: "Katalog" + share: "Del assistent" + mine: "Mine assistenter" + favorites: "Favoritter" + collections: "Samlinger" search: - legend: 'Find en assistent' - submit: 'Søg' - placeholder: 'Søg efter borgerservice, referat, journalisering…' + legend: "Find en assistent" + submit: "Søg" + placeholder: "Søg efter borgerservice, referat, journalisering…" tag: - badge_default: 'snart' - title_default: 'Kommer snart' + badge_default: "snart" + title_default: "Kommer snart" frontpage: - title: '%brand% – forhåndsvisning' + title: "%brand% – forhåndsvisning" hero: - eyebrow: 'Del & hjemtag · dansk offentlig AI' + eyebrow: "Del & hjemtag · dansk offentlig AI" heading_html: 'Et fælles bibliotek over kommunale AI-assistenter.' - lead: 'Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt.' + lead: "Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt." stats: - assistants: 'Assistenter' - kommuner: 'Kommuner' - models: 'Sprogmodeller' + assistants: "Assistenter" + kommuner: "Kommuner" + models: "Sprogmodeller" rail: - eyebrow: 'Forhåndsvisning' - link: 'Kataloget kommer →' - list_label: 'Eksempler på assistenter' + eyebrow: "Forhåndsvisning" + link: "Kataloget kommer →" + list_label: "Eksempler på assistenter" steps: - eyebrow: 'Sådan virker det' - '1': - lead: 'Opret bruger' - body: ' og log ind som repræsentant for din myndighed.' - '2': - lead: 'Find en assistent' - body: ' – søg og filtrér på kommune, sprogmodel og datafølsomhed.' - '3': - lead: 'Hjemtag' - body: ' – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere.' - '4': - lead: 'Tilpas lokalt' - body: ' – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug.' + eyebrow: "Sådan virker det" + "1": + lead: "Opret bruger" + body: " og log ind som repræsentant for din myndighed." + "2": + lead: "Find en assistent" + body: " – søg og filtrér på kommune, sprogmodel og datafølsomhed." + "3": + lead: "Hjemtag" + body: " – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere." + "4": + lead: "Tilpas lokalt" + body: " – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug." From 90ead51b90f28548a82bd555c7e2aebba365ef20 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 10:29:33 +0200 Subject: [PATCH 16/24] docs(changelog): drop test-related entries as baseline noise PHPUnit harness and the frontpage smoke test stay in the codebase; having tests is the default expectation for the project, not a noteworthy user-facing change. Drops the two related bullets from the [Unreleased] block. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1dabf..f2b94c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (stack, structure, execution policy, branching, commits, CHANGELOG, ADRs, domain glossary). - Initial Symfony 8 application scaffold. -- PHPUnit test harness with 100% coverage gate enforced in CI via - `rregeer/phpunit-coverage-check` - ([#31](https://github.com/itk-dev/ai-lib/issues/31)). ### Changed @@ -43,9 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 matching the prototype palette and typography. - Stimulus controller `nav_toggle_controller` driving the mobile navigation menu. -- Functional smoke test for the frontpage - (`tests/Controller/FrontpageControllerTest.php`); will start running - once PHPUnit lands (#31). - GitHub Action `block-on-label` that fails the check while a `do-not-merge` label is applied to a pull request, providing a per-PR merge gate for dependencies (e.g. another PR that must land From cc5ed781bd27c38f0a15242bca493488cef903b8 Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 11 Jun 2026 10:40:59 +0200 Subject: [PATCH 17/24] Added tests for nodevisitor --- config/reference.php | 12 ------------ src/Kernel.php | 8 -------- src/Twig/DevTemplateMarkerNodeVisitor.php | 6 ++---- 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/config/reference.php b/config/reference.php index f852f60..5fbb8a5 100644 --- a/config/reference.php +++ b/config/reference.php @@ -815,17 +815,6 @@ * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, * twig_component?: TwigComponentConfig, - * "when@dev"?: array{ - * imports?: ImportsConfig, - * parameters?: ParametersConfig, - * services?: ServicesConfig, - * framework?: FrameworkConfig, - * twig?: TwigConfig, - * twig_extra?: TwigExtraConfig, - * stimulus?: StimulusConfig, - * symfonycasts_tailwind?: SymfonycastsTailwindConfig, - * twig_component?: TwigComponentConfig, - * }, * "when@prod"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -930,7 +919,6 @@ public static function config(array $config): array * deprecated?: array{package:string, version:string, message?:string}, * } * @psalm-type RoutesConfig = array{ - * "when@dev"?: array, * "when@prod"?: array, * "when@test"?: array, * ... diff --git a/src/Kernel.php b/src/Kernel.php index a00950f..779cd1f 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -8,12 +8,4 @@ class Kernel extends BaseKernel { use MicroKernelTrait; - - /** - * @return list An array of allowed values for APP_ENV - */ - private function getAllowedEnvs(): array - { - return ['prod', 'dev', 'test']; - } } diff --git a/src/Twig/DevTemplateMarkerNodeVisitor.php b/src/Twig/DevTemplateMarkerNodeVisitor.php index 4d8bfae..40aa315 100644 --- a/src/Twig/DevTemplateMarkerNodeVisitor.php +++ b/src/Twig/DevTemplateMarkerNodeVisitor.php @@ -8,6 +8,7 @@ use Twig\Node\BlockNode; use Twig\Node\ModuleNode; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\TextNode; use Twig\NodeVisitor\NodeVisitorInterface; @@ -82,9 +83,6 @@ public function leaveNode(Node $node, Environment $env): Node */ private function wrapExtendingBody(ModuleNode $node, TextNode $prefix, TextNode $suffix): void { - if (!$node->hasNode('blocks')) { - return; - } $blocks = $node->getNode('blocks'); if (!$blocks->hasNode('body')) { return; @@ -120,7 +118,7 @@ public function getPriority(): int private function wrap(Node $node, TextNode $prefix, TextNode $suffix): void { $body = $node->getNode('body'); - $wrapped = new Node([$prefix, $body, $suffix], [], $body->getTemplateLine()); + $wrapped = new Nodes([$prefix, $body, $suffix], $body->getTemplateLine()); $node->setNode('body', $wrapped); } } From d679b262a770bdfa4de22a1e1fa15a3a3da8951b Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 11 Jun 2026 10:41:06 +0200 Subject: [PATCH 18/24] Added tests for nodevisitor --- tests/Twig/DevTemplateMarkerExtensionTest.php | 36 +++++++ .../Twig/DevTemplateMarkerNodeVisitorTest.php | 93 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/Twig/DevTemplateMarkerExtensionTest.php create mode 100644 tests/Twig/DevTemplateMarkerNodeVisitorTest.php diff --git a/tests/Twig/DevTemplateMarkerExtensionTest.php b/tests/Twig/DevTemplateMarkerExtensionTest.php new file mode 100644 index 0000000..8005785 --- /dev/null +++ b/tests/Twig/DevTemplateMarkerExtensionTest.php @@ -0,0 +1,36 @@ +getNodeVisitors(); + + self::assertCount(1, $visitors); + self::assertInstanceOf(DevTemplateMarkerNodeVisitor::class, $visitors[0]); + } + + public function testRegistersNoVisitorInProdEnvironment(): void + { + self::assertSame( + [], + (new DevTemplateMarkerExtension('prod'))->getNodeVisitors(), + ); + } + + public function testRegistersNoVisitorInTestEnvironment(): void + { + self::assertSame( + [], + (new DevTemplateMarkerExtension('test'))->getNodeVisitors(), + ); + } +} diff --git a/tests/Twig/DevTemplateMarkerNodeVisitorTest.php b/tests/Twig/DevTemplateMarkerNodeVisitorTest.php new file mode 100644 index 0000000..3ffa965 --- /dev/null +++ b/tests/Twig/DevTemplateMarkerNodeVisitorTest.php @@ -0,0 +1,93 @@ +render(['hello.html.twig' => '

    hi

    ']); + + self::assertSame( + '

    hi

    ', + $output, + ); + } + + public function testWrapsBodyBlockOfExtendingTemplate(): void + { + $output = $this->render([ + 'base.html.twig' => '[{% block body %}{% endblock %}]', + 'child.html.twig' => '{% extends "base.html.twig" %}{% block body %}hi{% endblock %}', + ], 'child.html.twig'); + + // base.html.twig is itself a non-extending template and also gets + // its body wrapped. child.html.twig's markers live inside the + // `body` block, between base's markers. + self::assertSame( + '[hi]', + $output, + ); + } + + public function testExtendingTemplateWithoutBodyBlockIsLeftAlone(): void + { + $output = $this->render([ + 'base.html.twig' => '[{% block other %}fallback{% endblock %}]', + 'child.html.twig' => '{% extends "base.html.twig" %}{% block other %}hi{% endblock %}', + ], 'child.html.twig'); + + // No `body` block in the chain, so child.html.twig contributes no + // markers. Base still gets its own. + self::assertSame('[hi]', $output); + } + + public function testNamespacedTemplateIsSkipped(): void + { + $output = $this->render(['@vendor/widget.html.twig' => '

    vendor

    ']); + + self::assertSame('

    vendor

    ', $output); + } + + public function testEnterNodeIsAPassThrough(): void + { + $visitor = new DevTemplateMarkerNodeVisitor(); + $env = new Environment(new ArrayLoader([])); + $node = new EmptyNode(); + + self::assertSame($node, $visitor->enterNode($node, $env)); + } + + public function testLeaveNodeIgnoresNonModuleNodes(): void + { + $visitor = new DevTemplateMarkerNodeVisitor(); + $env = new Environment(new ArrayLoader([])); + $node = new EmptyNode(); + + self::assertSame($node, $visitor->leaveNode($node, $env)); + } + + public function testPriorityIsZero(): void + { + self::assertSame(0, (new DevTemplateMarkerNodeVisitor())->getPriority()); + } + + /** + * @param array $templates + */ + private function render(array $templates, ?string $name = null): string + { + $env = new Environment(new ArrayLoader($templates), ['cache' => false]); + $env->addNodeVisitor(new DevTemplateMarkerNodeVisitor()); + + return $env->render($name ?? array_key_first($templates)); + } +} From 4164247ac69740562fba8f7303d1e893b33828e9 Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 11 Jun 2026 11:23:21 +0200 Subject: [PATCH 19/24] Reverted CHANGELOG change --- CHANGELOG.md | 3 +++ config/reference.php | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b94c6..30aa2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (stack, structure, execution policy, branching, commits, CHANGELOG, ADRs, domain glossary). - Initial Symfony 8 application scaffold. +- PHPUnit test harness with 100% coverage gate enforced in CI via + `rregeer/phpunit-coverage-check` + ([#31](https://github.com/itk-dev/ai-lib/issues/31)). ### Changed diff --git a/config/reference.php b/config/reference.php index 5fbb8a5..f852f60 100644 --- a/config/reference.php +++ b/config/reference.php @@ -815,6 +815,17 @@ * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, * twig_component?: TwigComponentConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * twig?: TwigConfig, + * twig_extra?: TwigExtraConfig, + * stimulus?: StimulusConfig, + * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, + * }, * "when@prod"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -919,6 +930,7 @@ public static function config(array $config): array * deprecated?: array{package:string, version:string, message?:string}, * } * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, * "when@prod"?: array, * "when@test"?: array, * ... From 466c3838eb05443097700cd3d598ada894fa9af7 Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 11 Jun 2026 11:32:55 +0200 Subject: [PATCH 20/24] Reverted a deleted method in Kernel --- src/Kernel.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Kernel.php b/src/Kernel.php index 779cd1f..a00950f 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -8,4 +8,12 @@ class Kernel extends BaseKernel { use MicroKernelTrait; + + /** + * @return list An array of allowed values for APP_ENV + */ + private function getAllowedEnvs(): array + { + return ['prod', 'dev', 'test']; + } } From 26930abd983dafa09de823ce505880a021d4f8bd Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 11 Jun 2026 11:33:15 +0200 Subject: [PATCH 21/24] Added Kernel test --- tests/KernelTest.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/KernelTest.php diff --git a/tests/KernelTest.php b/tests/KernelTest.php new file mode 100644 index 0000000..c7e1a2e --- /dev/null +++ b/tests/KernelTest.php @@ -0,0 +1,27 @@ +invoke($kernel)); + } +} From 090f849d0079e5a80adf09cd78aa03bdcc6f13f0 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 12:07:09 +0200 Subject: [PATCH 22/24] refactor: .env is the only source of BRAND_* defaults Drops the brand.default_* parameters in config/services.yaml and the corresponding default: fallbacks in the Twig globals. .env already ships the BRAND_NAME/BRAND_TAGLINE/BRAND_INITIALS values, so the parameter-backed safety net just duplicated that source and would silently drift if one side changed without the other. Addresses tuj's review on PR #43. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/packages/twig.yaml | 6 +++--- config/services.yaml | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index c9cc527..8a505e8 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,9 +1,9 @@ twig: file_name_pattern: '*.twig' globals: - brand_name: '%env(default:brand.default_name:BRAND_NAME)%' - brand_tagline: '%env(default:brand.default_tagline:BRAND_TAGLINE)%' - brand_initials: '%env(default:brand.default_initials:BRAND_INITIALS)%' + brand_name: '%env(BRAND_NAME)%' + brand_tagline: '%env(BRAND_TAGLINE)%' + brand_initials: '%env(BRAND_INITIALS)%' when@test: twig: diff --git a/config/services.yaml b/config/services.yaml index 7265e7b..7adddb9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,10 +7,6 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: - # Brand identity defaults — overridden by BRAND_* environment variables. - brand.default_name: 'AI Bibliotek' - brand.default_tagline: 'del & hjemtag assistenter · prototype' - brand.default_initials: 'AI' services: # default configuration for services in *this* file From 5abc7a92994d35568660081427d392f887d4a06e Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 12:07:17 +0200 Subject: [PATCH 23/24] fix(a11y): SearchBox label describes the field, not the submit action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The