diff --git a/.github/workflows/dockerhub-metadata.yml b/.github/workflows/dockerhub-metadata.yml index b315a2f..b229572 100644 --- a/.github/workflows/dockerhub-metadata.yml +++ b/.github/workflows/dockerhub-metadata.yml @@ -54,6 +54,20 @@ jobs: fi echo "token=$token" >> "$GITHUB_OUTPUT" + # Decode the JWT and refuse to run with an expired/stale token: a 403 five minutes + # later is much harder to diagnose than a clear failure here. Hub JWTs live ~5 min. + claims="$(jq -rn --arg t "$token" ' + ($t | split(".")[1] | gsub("-";"+") | gsub("_";"/") + | . + ("=" * ((4 - length % 4) % 4))) as $p + | ($p | @base64d | fromjson)')" + exp="$(jq -r '.exp // 0' <<< "$claims")" + now="$(date +%s)" + if [ "$exp" -le "$now" ]; then + echo "::error::Docker Hub token is already expired (exp=$exp, now=$now) — re-run the job." >&2 + exit 1 + fi + echo " token valid for $(( exp - now )) more seconds" + - name: Push descriptions and categories env: JWT: ${{ steps.auth.outputs.token }} @@ -66,14 +80,22 @@ jobs: echo "── techbuzzz/$repo" full="$(jq -Rs . < "dockerhub/$repo.md")" - jq -n --arg d "$short" --arg f "$full" \ + desc_resp="$(jq -n --arg d "$short" --arg f "$full" \ '{description:$d, full_description:$f}' \ - | curl -fsS -X PATCH \ - -H "Authorization: JWT ${JWT}" \ + | curl -sS -w $'\n%{http_code}' -X PATCH \ + -H "Authorization: Bearer ${JWT}" \ -H 'Content-Type: application/json' \ --data-binary @- \ - "https://hub.docker.com/v2/repositories/techbuzzz/$repo/" > /dev/null - echo " description + overview synced" + "https://hub.docker.com/v2/repositories/techbuzzz/$repo/")" + desc_code="${desc_resp##*$'\n'}" + if [ "$desc_code" = "200" ] || [ "$desc_code" = "201" ]; then + echo " description + overview synced" + else + echo "::error::description PATCH rejected for $repo (HTTP $desc_code)" >&2 + echo " Docker Hub said: $(printf '%s' "${desc_resp%$'\n'*}" | head -c 600)" >&2 + echo " If this is a 403, DOCKERHUB_TOKEN needs Read/Write/Delete scope on the techbuzzz namespace." >&2 + exit 1 + fi # Categories come from a fixed Docker Hub taxonomy keyed by slug, not by the # display name shown in the UI, so the slugs below are the part most likely to @@ -82,7 +104,7 @@ jobs: # body, so print it instead of discarding it. cats_resp="$(jq -nc --argjson c "$categories" '{categories:$c}' \ | curl -sS -w $'\n%{http_code}' -X PATCH \ - -H "Authorization: JWT ${JWT}" \ + -H "Authorization: Bearer ${JWT}" \ -H 'Content-Type: application/json' \ --data-binary @- \ "https://hub.docker.com/v2/repositories/techbuzzz/$repo/")" @@ -104,7 +126,7 @@ jobs: run: | set -euo pipefail for repo in delibera-server delibera-webui; do - body="$(curl -fsS -H "Authorization: JWT ${JWT}" \ + body="$(curl -fsS -H "Authorization: Bearer ${JWT}" \ "https://hub.docker.com/v2/repositories/techbuzzz/$repo/")" desc="$(jq -r '.description // ""' <<< "$body")" len="$(jq -r '(.full_description // "") | length' <<< "$body")" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dbcf5ea..4169f14 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,17 +1,17 @@ -name: Deploy Nuxt site to Pages +name: pages -# Publishes the real Delibera Web UI to GitHub Pages as a static build. +# Builds the Next.js site in site/ and publishes it to GitHub Pages on every merge to main. # -# This is a STATIC export of the same app that ships in the container. The important -# consequence, which is not obvious from the workflow: `nuxt generate` produces no Nitro server, -# so the BFF route `/api/delibera/**` does not exist in the deployed site. The browser then needs -# an absolute API origin, supplied at build time by NUXT_PUBLIC_DELIBERA_API_BASE and resolved in -# app/composables/useDebates.ts. That server must allow this origin via CORS — Delibera.Server -# registers no CORS policy by default, because in its normal container deployment the browser -# only ever talks to the same-origin proxy. +# The site is a *view* over files that already live in this repository: the guides and +# release notes in docs/, the changelog, the version in Delibera.Core.csproj, and the debate +# templates in DebateTemplate.cs. Nothing is copied into a second location, so a page cannot +# advertise a version, a feature or a measurement the code does not have. # -# Without that env var the site still deploys and still renders, but every request 404s. That is -# a legitimate state for a UI preview; it is not a working install. +# The Nuxt Web UI is NOT deployed here. It ships in the container image +# (techbuzzz/delibera-webui) and is meant to run next to Delibera.Server, which provides the +# same-origin BFF route /api/delibera/**. A static export has no Nitro server and therefore +# no BFF, so the UI would need a separate CORS-enabled API origin — a deliberate owner +# decision, not something this workflow should configure silently. on: push: @@ -23,160 +23,60 @@ permissions: pages: write id-token: write -# Allow only one concurrent deployment, skipping runs queued between the in-progress and the -# latest queued. Do NOT cancel in-progress runs — let a production deploy finish. +# A new push supersedes an in-flight deploy; without this the second run would fail on the +# concurrency lock and leave the site one deploy behind. concurrency: group: pages - cancel-in-progress: false + cancel-in-progress: true jobs: build: + name: Build runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Detect package manager - id: detect-package-manager - run: | - set -euo pipefail - if [ -f "${{ github.workspace }}/src/Delibera.WebUI/pnpm-lock.yaml" ]; then - echo "manager=pnpm" >> "$GITHUB_OUTPUT" - echo "lockfile=pnpm-lock.yaml" >> "$GITHUB_OUTPUT" - echo "command=install" >> "$GITHUB_OUTPUT" - elif [ -f "${{ github.workspace }}/src/Delibera.WebUI/yarn.lock" ]; then - echo "manager=yarn" >> "$GITHUB_OUTPUT" - echo "lockfile=yarn.lock" >> "$GITHUB_OUTPUT" - echo "command=install" >> "$GITHUB_OUTPUT" - elif [ -f "${{ github.workspace }}/src/Delibera.WebUI/package-lock.json" ]; then - echo "manager=npm" >> "$GITHUB_OUTPUT" - echo "lockfile=package-lock.json" >> "$GITHUB_OUTPUT" - echo "command=ci" >> "$GITHUB_OUTPUT" - else - echo "::error::no lockfile found in src/Delibera.WebUI" >&2 - exit 1 - fi - - - name: Setup Node - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: - node-version: '22' - cache: ${{ steps.detect-package-manager.outputs.manager }} - # Must follow the manager the detect step chose. Hardcoding the npm lockfile works - # today because package-lock.json is the only one in the tree — but if this repo ever - # adopts pnpm, a pnpm cache keyed on a file that does not exist never invalidates, and - # a stale node_modules is restored forever under a green build. - cache-dependency-path: src/Delibera.WebUI/${{ steps.detect-package-manager.outputs.lockfile }} + node-version: 22 + cache: npm + cache-dependency-path: site/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: site - name: Setup Pages id: pages uses: actions/configure-pages@v5 - # No `static_site_generator: nuxt` here, and that is deliberate. That input makes the - # action REWRITE the generator config file instead of exporting anything, and it only - # understands .js/.cjs/.mjs — our nuxt.config.ts is TypeScript, so it emits a warning and - # injects nothing. The keys it would inject (`router.base`, `target`) are Nuxt 3 names - # that Nuxt 4 has moved or removed anyway. The base path is taken from this step's - # `base_path` output and passed to the build as NUXT_APP_BASE_URL instead. - - - name: Install dependencies - working-directory: src/Delibera.WebUI - run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} - - - name: Typecheck - working-directory: src/Delibera.WebUI - run: ${{ steps.detect-package-manager.outputs.manager }} run typecheck - - name: Static HTML export with Nuxt - working-directory: src/Delibera.WebUI + - name: Build + run: npm run build + working-directory: site env: - # Empty by default: the site deploys as a UI preview whose API calls 404. - # Set it as a repository variable to point the static build at a live server. - NUXT_PUBLIC_DELIBERA_API_BASE: ${{ vars.NUXT_PUBLIC_DELIBERA_API_BASE }} - # The site's own base path (e.g. /Delibera). Nuxt reads NUXT_APP_BASE_URL into - # app.baseURL, so the export keeps its documents at the artifact root while asset - # URLs are prefixed with the base — which is exactly what Pages expects. - NUXT_APP_BASE_URL: ${{ steps.pages.outputs.base_path }} - run: ${{ steps.detect-package-manager.outputs.manager }} run generate + # configure-pages computes the prefix for this repo's Pages URL (/Delibera) and + # Next.js needs it at build time — it is baked into every emitted href. + PAGES_BASE_PATH: ${{ steps.pages.outputs.base_path }} - - name: Fail if the export produced a real site - run: | - set -euo pipefail - # `.output/public`, NOT `dist` — Nuxt 4 moved the static output there. GitHub's own - # sample workflow still uploads ./dist because it predates Nuxt 4, and copying it - # verbatim uploads nothing at all. - # - # The second assertion matters more: without an explicit nitro.prerender.routes list - # the export contains only 200.html + 404.html and NO document for the real pages, - # which deploys as a blank site that still reports a green build. - out=src/Delibera.WebUI/.output/public - if [ ! -f "$out/index.html" ]; then - echo "::error::$out/index.html is missing — the site would deploy blank" >&2 - ls -la "$out" || true - exit 1 - fi - echo "prerendered: $(cd "$out" && find . -name '*.html' | sort | tr '\n' ' ')" - echo "total files: $(find "$out" -type f | wc -l)" - - - name: Fail if the configured API base did not reach the bundle + - name: Verify the export + run: node scripts/verify-export.mjs + working-directory: site env: - API_BASE: ${{ vars.NUXT_PUBLIC_DELIBERA_API_BASE }} - run: | - set -euo pipefail - # Nuxt substitutes `import.meta.env.NUXT_PUBLIC_*` only for keys declared under - # runtimeConfig.public. Undeclare or rename `deliberaApiBase` and nothing fails: the - # build goes green and the deployed site keeps calling its own origin, which on - # Pages is a 404. A green pipeline is exactly the signal that cannot catch this, - # so check the artefact instead of the exit code. - if [ -z "$API_BASE" ]; then - echo "no API base configured — deploying a UI preview whose requests 404" - exit 0 - fi - out=src/Delibera.WebUI/.output/public - if ! grep -rqF "$API_BASE" "$out"; then - echo "::error::$API_BASE is configured but appears nowhere in the export" >&2 - echo "Check that deliberaApiBase is still declared under runtimeConfig.public." >&2 - exit 1 - fi - echo "API base $API_BASE is baked into the export" - - - name: Fail if the Pages base path did not reach the export - run: | - set -euo pipefail - # configure-pages injects the site's base path into nuxt.config before the build, so - # a project site (techbuzzz.github.io/Delibera) emits /Delibera/_nuxt/... asset URLs - # while the files themselves stay at the root of the artifact. If the injection ever - # stops happening, the HTML keeps root-relative URLs: the deploy succeeds, and the - # site renders unstyled over a client bundle that 404s. - base="${{ steps.pages.outputs.base_path }}" - if [ -z "$base" ]; then - echo "no base path reported — an owner/org Pages site, nothing to check" - exit 0 - fi - html=src/Delibera.WebUI/.output/public/index.html - if grep -q "href=\"$base/_nuxt/" "$html"; then - echo "base path $base is applied to the export's asset URLs" - exit 0 - fi - echo "::error::expected asset URLs under $base/_nuxt/ in $html" >&2 - grep -o 'href="[^"]*_nuxt/[^"]*"' "$html" | head -3 >&2 || true - exit 1 + PAGES_BASE_PATH: ${{ steps.pages.outputs.base_path }} - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: src/Delibera.WebUI/.output/public + path: site/out deploy: + name: Deploy + runs-on: ubuntu-latest + needs: build environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build steps: - name: Deploy to GitHub Pages id: deployment - # v5 is current (5.0.1, Sep 2026) and is what GitHub's own sample pairs with - # upload-pages-artifact@v3. It stays on upload-artifact@v4 internals, so v3 artifacts - # are still the supported input — no reason to move the upload major and re-test a - # tarball format nobody here can reproduce locally. - uses: actions/deploy-pages@v5 \ No newline at end of file + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index c6c0d1f..8965d04 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -95,6 +95,19 @@ jobs: timeout-minutes: 45 permissions: contents: read + # Same protection layer NuGet has, and the reason it is not enough to rely on the tag + # gate above. + # + # The `if:` already stops a merge to `main` from publishing — but it does nothing about a + # mistyped tag, or about anyone able to push a `v*` tag. That path overwrites a published + # `latest` on a registry consumers pull from, which is not undoable by re-tagging. + # + # The environment is where `Required reviewers` lives. With it enabled on + # `dockerhub-prod`, the run pauses at this job and a human approves the push; without it + # the block is only scoping for secrets, the URL below, and the deployment history. + environment: + name: dockerhub-prod + url: https://hub.docker.com/r/techbuzzz/delibera-server steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dc786b..14c8d24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,13 @@ name: release # knows which of those commits mattered. # # Kept separate from publish-nuget.yml so a release-page failure cannot fail a package publish. +# +# Tag-only since the merge-squash cleanup. It used to also run on every push to `main`, where it +# resolved the newest reachable v* tag and rewrote that release's body. That cost a full +# fetch-depth-0 clone, a PR-notes generation and a release API call on every merge, to produce +# a body that usually had not changed — and "usually" is the problem: a merge that happened to +# touch the header or the WhatsNew doc rewrote a *published* release's notes after the fact. +# The repair path is still one click: Actions -> release -> Run workflow. on: workflow_dispatch: @@ -22,7 +29,6 @@ on: type: string push: tags: [ 'v*' ] - branches: [ main ] permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7ec3c..9a9e7e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,163 @@ All notable changes to **Delibera** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [10.5.3] - 2026-10-11 + +**No breaking changes.** This release adds opt-in CORS, a project website on GitHub Pages, +Web UI accessibility and behaviour fixes, server performance improvements, and a BuiltIn +constants refactor. Docker Hub and NuGet publish workflows are now behind approval +environments. + +### Added — project website on GitHub Pages + +`pages.yml` now builds and deploys a Next.js static site in `site/` rather than the Nuxt +Web UI. The site is a view over this repository: every page is rendered at build time from +content that already lives here — the version from `Delibera.Core.csproj`, docs from +`docs/`, release notes from `docs/WhatsNew-*.md`, measurements from +`docs/performance-measurements.md`, and debate templates extracted from `DebateTemplate.cs`. + +The docs route is a catch-all, so nested files keep their directory in the URL. Adding a +Markdown file under `docs/` adds a page automatically. A broken cross-link fails the deploy: +`scripts/verify-export.mjs` walks the export and fails on an internal href with no file +behind it, a missing `/Delibera` prefix, or a `#fragment` pointing at a renamed heading. + +The Web UI is **not** deployed to Pages. It ships in the `techbuzzz/delibera-webui` +container image and is meant to run next to `Delibera.Server`, which provides the +same-origin BFF route `/api/delibera/**`. + +### Added — opt-in CORS for static browser clients + +`Delibera:Server:Cors:AllowedOrigins` registers a named CORS policy listing the browser +origins permitted to call the API cross-origin, and `UseCors` is wired only when that +list is non-empty. + +**Off by default, and the default is the point.** The Web UI reaches the API through its +own same-origin BFF route and never needs CORS. The absent header is also what stops an +arbitrary web page from *reading* a response — a cross-origin `fetch` still fires the +request without it, it just cannot see the answer. Turning this on globally would have +added a browser-side CSRF vector to an API that is unauthenticated, unthrottled, and spends +real credits per debate. + +Set it only for a deliberately static client that calls the API cross-origin: + +```yaml +environment: + DELIBERA_CORS_ORIGIN: https://example.com +``` + +Origins are matched exactly; there is no wildcard switch. `AllowCredentials` together with +`*` is rejected at startup with a message naming the config key, rather than by the +framework with one that names nothing. 13 new tests: 8 asserting what gets registered, and +5 driving the real pipeline over HTTP — that a default host emits no +`Access-Control-Allow-Origin`, that a configured origin gets its own origin echoed back +rather than a wildcard, that an unlisted origin gets nothing, that the preflight is +answered, and that `X-Correlation-Id` is exposed. + +### Added — BuiltIn constants + +`src/Delibera.Core/BuiltIn.cs` — a single source of truth for HTTP headers, SSE event names, +config sections, role names, model names, timeouts, Redis keys, and other magic strings that +previously drifted between Delibera.Core, Delibera.Redis, Delibera.Server and consumer +applications. SSE event names remain a contract with the Web UI. + +### Changed — C# 15 where it compiles, and the two features that need .NET 11 + +`LangVersion=preview` was already set across the solution, but what that actually buys depended +on which SDK was installed: 10.0.112 ships Roslyn 5.0 and knows no C# 15 at all, while 10.0.401 +ships Roslyn 5.9. A `global.json` now pins `10.0.401` (`rollForward: latestFeature`) so a machine +without it cannot silently fail to compile. `Delibera.Redis` gained the `LangVersion=preview` the +other six source projects already had. + +Union types and closed hierarchies compile as keywords but are rejected on `net10.0` — they need +`System.Runtime.CompilerServices.IUnion` and `IsClosedTypeAttribute`, which ship with **.NET 11**. +They are deferred to the `net11.0` retarget along with the runtime work it unlocks. The full +feature gate is recorded in `docs/NET10-Upgrade.md`. + +What did ship is collection expression arguments on the paths that build a collection whose final +size is known: voting score tables, chunk lists, sentence-selection and deduplication buffers, and +the Knowledge Keeper's multi-corpus merge. Borda counting also stops materialising a sorted copy of +each ballot, and the semantic compressor no longer materialises a sorted copy of every sentence it +is about to iterate over. + +### Fixed — three per-request costs on the server + +- `ValidationFilter` called `typeof(IValidator<>).MakeGenericType(arg.GetType())` for every + argument of every request. Building a closed generic type allocates a runtime type; the mapping + is now cached per argument type. +- `RedisDebateOrchestrator` opened its own `ConnectionMultiplexer`, bypassing the singleton the + host registers — a second TCP pool, handshake and reconnect timers for the life of the process, + for a class whose own documentation claimed it shared the connection with `RedisDebateCache`. + It now reuses the injected multiplexer when there is one, and still opens its own when + constructed outside DI. +- The SSE writer emitted each frame as six writes plus a flush, and re-encoded the event name from + string to UTF-8 on every event. A frame is now assembled once in the response pipe and flushed + once, with event names as `u8` literals. Per-frame flushing is unchanged deliberately: a + buffering proxy once passed every functional test here while delivering rounds 150–200 seconds + late. + +### Added — regression tests for both + +`Validator_Type_Is_Resolved_Once_Per_Contract_Not_Per_Request` fails if the cached validator type +disappears. `Round_Is_Flushed_While_The_Debate_Is_Still_Running` withholds the terminal event and +requires the round to reach the wire within three seconds; it was verified to fail when the +per-frame flush is removed. + +`docs/performance-measurements.md` records the before/after measurement, including the result that +the framework-overhead benchmark shows **no change** — those paths are not exercised by that +workload, and the server-side figures were not measured at all in this pass. + +### Changed — GitHub Pages deploys the project website, not the Web UI + +`pages.yml` previously built and deployed the Nuxt Web UI as a static export. That was wrong: the +Web UI is meant to ship in the container image (`techbuzzz/delibera-webui`) and run next to +`Delibera.Server`, which provides the same-origin BFF route `/api/delibera/**`. A static export has +no Nitro server and therefore no BFF, so the deployed site could only reach an API via a separate +CORS-enabled origin — a deliberate owner decision, not something the workflow should configure +silently. + +Pages now publishes a real project site rather than a single hand-written page. + +**The site is a view over this repository, not a copy of it.** `site/` is a Next.js app +configured for `output: 'export'` (following +[nextjs/deploy-github-pages](https://github.com/nextjs/deploy-github-pages)). Every page is +rendered at build time from content that already lives here: + +| Page | Source | +| --- | --- | +| `/` | version from `Delibera.Core.csproj`, release cards from `docs/WhatsNew-*.md` | +| `/docs//` | every Markdown file under `docs/`, at any depth | +| `/blog//` | `docs/WhatsNew-*.md` | +| `/measurements/` | `docs/performance-measurements.md` | +| `/templates/` | `src/Delibera.Core/Templates/DebateTemplate.cs` | +| `/changelog/` | `CHANGELOG.md`, split per release | + +The docs route is a catch-all, so nested files keep their directory in the URL and a +directory's `README.md` collapses to the directory itself — `docs/TASKS/README.md` is +`/docs/tasks/`, the URL the previous landing page already linked to. The work log in +`docs/TASKS/` and the planning notes in `docs/scope/` are published for the same reason: +they are committed and public, and dropping them would be a coverage regression dressed up +as a curation decision. + +Consequences worth stating, because they are the point: + +- **The version cannot be wrong.** It is read from the csproj that `publish-nuget.yml` actually + packs — not from a tag (which can be pushed before the package reaches nuget.org) and not from + this changelog (which is edited by hand). If it cannot be resolved, the build fails. +- **The templates page cannot be stale.** Roles, strategy, chairman stance and round count are + extracted from `DebateTemplate.cs` at build time. If the parse finds nothing, the page says so + rather than showing a list that no longer matches the package. +- **Adding a Markdown file adds a page.** No route list to update, and every existing cross-link + to that file starts resolving. Repository-relative links (`../CHANGELOG.md`, + `docs/performance-measurements.md#3-context-compression`) are rewritten to site routes; targets + the site does not serve become absolute GitHub links. +- **A broken cross-link fails the deploy.** `scripts/verify-export.mjs` walks the export and fails + on an internal href with no file behind it, a missing `/Delibera` prefix, or a `#fragment` + pointing at a renamed heading. All three pass a green `next build` and 404 in production — + GitHub Pages is case-sensitive while every local check is not. + +Code blocks are highlighted with Shiki using GitHub's own TextMate grammars, in both light and dark +themes, following `prefers-color-scheme`. The site ships no CSS framework and no client-side +JavaScript beyond the Next.js runtime. ### Added — opt-in CORS for static browser clients @@ -18,12 +174,11 @@ page from *reading* a response — a cross-origin `fetch` still fires the reques cannot see the answer. Turning this on globally would have added a browser-side CSRF vector to an API that is unauthenticated, unthrottled, and spends real credits per debate. -Set it only for a deliberately static client — the GitHub Pages build of the Web UI, which has no -Nitro server and therefore no BFF route. In compose: +Set it only for a deliberately static client that calls the API cross-origin. In compose: ```yaml environment: - DELIBERA_CORS_ORIGIN: https://techbuzzz.github.io + DELIBERA_CORS_ORIGIN: https://example.com ``` Origins are matched exactly; there is no wildcard switch. `AllowCredentials` together with `*` @@ -33,19 +188,73 @@ pipeline over HTTP — that a default host emits no `Access-Control-Allow-Origin configured origin gets its own origin echoed back rather than a wildcard, that an unlisted origin gets nothing, that the preflight is answered, and that `X-Correlation-Id` is exposed. -### Fixed — the static Web UI build ignored its configured API origin - -`NUXT_PUBLIC_DELIBERA_API_BASE` did not reach the bundle, so a GitHub Pages build configured with -it still called its own origin and every request 404'd. `pages.yml` was green throughout: Nuxt -substitutes `import.meta.env.NUXT_PUBLIC_*` only for names declared under `runtimeConfig.public`, -and an undeclared one is silently left as-is rather than rejected. `deliberaApiBase` is now -declared (empty, so the container's same-origin behaviour is unchanged), and `pages.yml` asserts -that a configured origin actually appears in the export — a dead site is no longer able to report -a successful deployment. - -Verified: a build with the variable set carries it into 4 places in the export; a build without it -carries it into none; the new gate passes when the origin is present, fails naming the missing -config key when it is not, and is a no-op in preview mode. Web UI tests 34/34. +### Fixed — the Web UI typecheck was passing while silently checking less + +`npm run typecheck` printed `[Vue] Load plugin failed: vue-router/volar/sfc-route-blocks` on every +run and exited **0**, so CI reported green. The plugin Nuxt writes into the generated tsconfig +comes from `vue-router`, and the app pinned `vue-router@^4` — a different major from the one +`nuxt` itself depends on, for a package no source file imports. Two defects sat behind the one +message: `vue-tsc@2` cannot load a `vue-router@5` plugin at all, and once that is upgraded the +plugin must also be resolvable from the project root, which it is not while it only exists +nested under `node_modules/nuxt`. Nuxt is now `4.6.1`, `vue-router@^5.4.0` matching it, +`vue-tsc@3.3`, `vitest@5`, `@nuxt/test-utils@4`, and the check is clean for the first time. + +### Changed — Node is pinned to an exact patch, because the requirement is an exact range + +`nuxt@4.6.1` declares `engines.node: ^22.22.3 || ^24.15.0 || >=26.0.0`, so the floating +`node:22-alpine` and `.nvmrc: 22` that worked by accident stop working the moment the tag moves +against us. Both are now `22.23.3`, the Dockerfile resolving one `ARG NODE_VERSION` across its +three stages. Node 24 ("Krypton") satisfies the range too and is the natural move before 22 +("Jod") leaves maintenance in April 2027 — but that also moves the bundled npm from 10.x to +11.x, which deserves its own change. + +### Fixed — the proxy kept upstream SSE connections open after the browser left + +Every upstream call runs with `timeout: 0`, because a debate legitimately takes 150–200 s and its +stream stays open longer still, so nothing else ever ended those requests. Closing the tab, +navigating away or letting `EventSource` reconnect left the server-side connection running for +the remainder of the debate — one leaked stream per piece of user navigation, invisible from a UI +that had simply gone. The proxy now aborts its own fetch when the response closes before it was +fully written, and the resulting read error is swallowed rather than thrown: a client hanging up +is not an upstream fault worth reporting to nobody. + +Measured on the test fixture, the upstream socket closes **6 ms** after the client disconnects +with the fix and **2699 ms** without it — the second being the moment the fake upstream ends its +own response, which is exactly the point: a connection the proxy never cancelled closes anyway. +The regression test asserts the timing, not the count, because the count passes either way. + +### Fixed — three Web UI behaviours that were silently wrong + +- **Cancel reported itself without saying anything.** Both branches of `onCancel` called + `loadRecord()`, so "cancelled" and "had already finished" produced identical silence and an + unexplained status change. They now say which one happened. +- **Changing a filter did not re-query.** The selects only changed what was *selected*; the list + kept rendering the previous filter's results under controls claiming the new one, and paging + was never reset. A filter change now reloads from page 1. +- **`npm test` failed on a clean checkout.** The SSE suite runs the built Nitro server, and CI + only passed because its `build` step happens to run first. `pretest` now builds. + +### Added — the live view announces itself, and the pages identify themselves + +Rounds arriving over SSE were invisible to a screen reader: the count in the heading changed +silently, and the reader was most likely on the verdict panel. A polite live region now announces +each round — polite, because rounds arrive as a steady drip and interrupting whatever is being +read for each one would make the page unusable rather than live. Every tab was titled "Delibera — +AI council"; each route now titles itself, and a debate names itself by its label once loaded. +Buttons, inputs and rows gained hover states and a visible `:focus-visible` ring, the browser +default being near-invisible against these dark surfaces, and the error page now shows the status +message and clears the error through `clearError` instead of relying on a link that renders +without the router state it needs. + +### Changed — the server route stays on `h3`, and says why + +Nuxt 4.6 added `nuxt/server`, a server-agnostic surface that re-declares the request helpers +against Nuxt's own `RequestEvent` and omits the response-writing helpers, so a non-Nitro builder +can satisfy it. This route is exactly the case it cannot serve: forwarding an upstream body +verbatim is `sendWebResponse`, streaming SSE chunk by chunk is `sendStream`, and Nuxt's +`RequestEvent` is not assignable to h3's `H3Event` — mixing the surfaces fails typecheck, not at +runtime. The imports stay on `h3` with the reasoning recorded at the import, so the next person +does not spend the same afternoon on it. An unused `H3Event` type import went with it. ## [10.5.2] - 2026-10-09 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a033a..9d15a04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,19 +133,30 @@ W5-09). ## 🌿 Branch & Commit Conventions -- Create a feature branch: `git checkout -b feature/short-description`. +- Create a feature branch from an up-to-date `main`: `git checkout -b feature/short-description`. - Write clear, imperative commit messages (e.g., `Add consensus debate timeout option`). - Reference related issues in the body (e.g., `Closes #42`). +- Delete the branch after it merges. A branch is never reopened — a new change starts from a + fresh one off `main`. --- ## 🔀 Pull Request Process -1. Ensure your branch is up to date with `main`. +1. Ensure your branch is up to date with `main`: `git checkout main && git pull && git checkout && git merge main`. 2. Confirm the solution builds clean (0 errors, 0 warnings) and examples run. 3. Update documentation (`README.md`, `QuickStart.md`) when behaviour changes. 4. Open a PR using the [pull-request template](.github/PULL_REQUEST_TEMPLATE.md) and fill in all sections. 5. A maintainer will review; address feedback by pushing additional commits. +6. **Merge with a merge commit, not squash.** The PR's commits have to become ancestors of `main`: + that is what keeps `git merge main` inside an open branch an ordinary fast-forward, and what + makes `git merge-base --is-ancestor main` able to answer whether the work actually + shipped. Squash-merge discards exactly that. `git log --first-parent main` is unaffected and + still reads as the release list. + + *Repository setting, not a workflow file:* **Settings → General → Pull Requests → Merge + options → Allow merge commits.** Squash and rebase can stay enabled for convenience — the + team just uses merge. --- diff --git a/README-RU.md b/README-RU.md index 57b96e4..662328a 100644 --- a/README-RU.md +++ b/README-RU.md @@ -15,7 +15,7 @@ [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-2496ED?logo=docker&logoColor=fff)](https://hub.docker.com/r/techbuzzz/delibera-server) [![Pulls: server](https://img.shields.io/docker/pulls/techbuzzz/delibera-server?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-server) [![Pulls: webui](https://img.shields.io/docker/pulls/techbuzzz/delibera-webui?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-webui) -[![Web UI](https://img.shields.io/badge/Web%20UI-GitHub%20Pages-3E8AFF?logo=githubpages)](https://techbuzzz.github.io/Delibera/) +[![Website](https://img.shields.io/badge/Website-GitHub%20Pages-3E8AFF?logo=githubpages)](https://techbuzzz.github.io/Delibera/) [![CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml) [![Docker CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) @@ -69,6 +69,27 @@ | **🤝 Microsoft.Extensions.AI** | Поддержка `IChatClient` / `IEmbeddingGenerator` — подключайте OpenAI, Azure OpenAI, Ollama и любые совместимые бэкенды, с middleware (function calling, логирование) | | **🧱 Современный C# 15 (preview)** | Построено на .NET 10 с `LangVersion=preview`, file-scoped namespaces, records, span/SIMD горячие пути | +### 🆕 Что нового в v10.5.3 + +Опциональный CORS для статических браузерных клиентов, сайт проекта на GitHub Pages, +улучшения доступности и исправления поведения Web UI, улучшения производительности сервера +и рефакторинг констант BuiltIn. **Без ломающих изменений.** + +| Возможность | Описание | +| --- | --- | +| **🌐 Сайт проекта** | Статический сайт на Next.js на GitHub Pages — документация на любой глубине, заметки о релизах, измерения, шаблоны дебатов, извлечённые из исходников. Версия читается из csproj во время сборки и не может быть неверной. | +| **🔓 Опциональный CORS** | `Delibera:Server:Cors:AllowedOrigins` регистрирует именованную CORS-политику для намеренно статических браузерных клиентов. По умолчанию выключен — Web UI использует свой same-origin BFF-маршрут. Origins сопоставляются точно; wildcard-выключателя нет. | +| **♿ Доступность** | Раунды объявляются скринридерам через polite live region, каждая страница имеет свой заголовок, добавлены hover-состояния и видимые кольца фокуса. | +| **⚡ Производительность** | ValidationFilter кэширует резолв типов валидаторов, RedisDebateOrchestrator переиспользует инжектированный ConnectionMultiplexer, SSE-писатель собирает фреймы один раз и флашит один раз. | +| **🔧 Константы BuiltIn** | Единый источник истинности для HTTP-заголовков, имён SSE-событий, секций конфигурации и имён ролей во всех проектах Delibera. | +| **🐛 Исправления Web UI** | Отмена различает «отменено» и «уже завершено», изменение фильтра перезагружает с первой страницы, npm test собирает перед запуском через pretest, прокси прерывает upstream SSE при отключении клиента (6 мс против 2699 мс). | +| **🔄 Улучшения CI** | Публикация в Docker Hub за окружением одобрения `dockerhub-prod`, страницы релизов строятся из WhatsNew-документов, merge-коммиты вместо squash. | +| **✅ Исправлено** | Typecheck Web UI проходил, молча проверяя меньше — Nuxt 4.6.1, vue-router ^5.4.0, vue-tsc 3.3, vitest 5. Lockfile перегенерирован под npm 10 в соответствии с пином в Dockerfile. | + +Подробности: [docs/WhatsNew-v10.5.3.md](docs/WhatsNew-v10.5.3.md). + +--- + ### 🆕 Что нового в v10.5.2 Браузерный интерфейс для работы с дебатами, реально подключённый Redis внутри сервера, образы в @@ -261,7 +282,7 @@ Redis остаётся **выключенным**, пока не задан `Del ```bash curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml -export DELIBERA_VERSION=10.5.2 # зафиксировать релиз; без переменной тянется :latest +export DELIBERA_VERSION=10.5.3 # зафиксировать релиз; без переменной тянется :latest docker compose -f docker-compose.hub.yml up -d ``` @@ -269,23 +290,20 @@ docker compose -f docker-compose.hub.yml up -d > Все публикуемые порты слушают `127.0.0.1`. Поставьте аутентифицирующий обратный прокси, прежде чем > открывать этот стек в сеть, которой не управляете. -#### 🌐 Веб-интерфейс на GitHub Pages - -Тот же интерфейс статически публикуется на при каждом -мердже в `main`. +#### 🌐 Сайт проекта на GitHub Pages -У статической сборки **нет сервера**, поэтому same-origin BFF-маршрута в ней не существует — нужен -абсолютный origin API, и тот сервер должен разрешать origin Pages через CORS: - -```yaml -environment: - DELIBERA_CORS_ORIGIN: https://techbuzzz.github.io -``` +Сайт проекта публикуется на при каждом мердже в `main`. +Он рендерит собственный Markdown этого репозитория — руководства из `docs/`, заметки о релизах, +отчёт о замерах, changelog и шаблоны дебатов, разобранные из `Delibera.Core.cs`, — поэтому +страница не может показывать версию, функцию или число, которых нет в коде. Подробности: +[`site/README.md`](site/README.md). -CORS **выключен по умолчанию** и остаётся выключенным, пока не перечислены origins. Это намеренно: -именно отсутствие заголовка не даёт случайной веб-странице прочитать ответ этого API, а контейнерному -Web UI он не нужен вовсе. Комбинация «wildcard + AllowCredentials» отклоняется на старте с сообщением, -называющим конкретный ключ конфигурации. +Сам веб-интерфейс **там не публикуется** — он поставляется в образе +[`techbuzzz/delibera-webui`](https://hub.docker.com/r/techbuzzz/delibera-webui) и предназначен для +работы рядом с `Delibera.Server`, который предоставляет same-origin BFF-маршрут +`/api/delibera/**`, от которого зависит UI. Статическая сборка не имеет Nitro-сервера и, значит, +BFF, поэтому ей потребовался бы отдельный API-origin с разрешённым CORS — это осознанное решение +владельца, а не то, что workflow Pages настраивает молча. #### 📥 Из исходников @@ -929,7 +947,7 @@ docker compose -f docker-compose.hub.yml up -d ``` Забирает `techbuzzz/delibera-server` и `techbuzzz/delibera-webui` из Docker Hub. Укажите -`DELIBERA_VERSION=10.5.2`, чтобы зафиксировать релиз. +`DELIBERA_VERSION=10.5.3`, чтобы зафиксировать релиз. ### Веб-интерфейс diff --git a/README.md b/README.md index 5f72b1f..8342fec 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-2496ED?logo=docker&logoColor=fff)](https://hub.docker.com/r/techbuzzz/delibera-server) [![Pulls: server](https://img.shields.io/docker/pulls/techbuzzz/delibera-server?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-server) [![Pulls: webui](https://img.shields.io/docker/pulls/techbuzzz/delibera-webui?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-webui) -[![Web UI](https://img.shields.io/badge/Web%20UI-GitHub%20Pages-3E8AFF?logo=githubpages)](https://techbuzzz.github.io/Delibera/) +[![Website](https://img.shields.io/badge/Website-GitHub%20Pages-3E8AFF?logo=githubpages)](https://techbuzzz.github.io/Delibera/) [![CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml) [![Docker CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) @@ -69,6 +69,27 @@ well-reasoned outcomes** rather than single-model guesses. | **🛑 Cooperative Cancellation**| Every public async method accepts a `CancellationToken`; a host shutdown or user cancel aborts the debate mid-flight (rounds, LLM calls, MCP tools, RAG, file saves) | | **🧱 Modern C# 15 (preview)** | Built on .NET 10 with `LangVersion=preview`, file-scoped namespaces, records, span/SIMD hot paths | +### 🆕 What's new in v10.5.3 + +Opt-in CORS for static browser clients, a project website on GitHub Pages, Web UI +accessibility and behaviour fixes, server-side performance improvements, and a BuiltIn +constants refactor. **No breaking changes.** + +| Feature | Description | +| --- | --- | +| **🌐 Project website** | Next.js static site on GitHub Pages — docs at any depth, release notes, measurements, debate templates extracted from source. The version is read from the csproj at build time and cannot be wrong. | +| **🔓 Opt-in CORS** | `Delibera:Server:Cors:AllowedOrigins` registers a named CORS policy for deliberately static browser clients. Off by default — the Web UI uses its same-origin BFF route. Origins matched exactly; no wildcard switch. | +| **♿ Accessibility** | Rounds announced to screen readers via a polite live region, every route titles itself, hover states and visible focus rings. | +| **⚡ Performance** | ValidationFilter caches validator type resolution, RedisDebateOrchestrator reuses the injected ConnectionMultiplexer, SSE writer assembles frames once and flushes once. | +| **🔧 BuiltIn constants** | Single source of truth for HTTP headers, SSE event names, config sections, and role names across all Delibera projects. | +| **🐛 Web UI fixes** | Cancel distinguishes "cancelled" from "already finished", filter change reloads from page 1, npm test builds first via pretest, proxy aborts upstream SSE on client disconnect (6 ms vs 2699 ms). | +| **🔄 CI improvements** | Docker Hub publish behind approval environment `dockerhub-prod`, release pages built from WhatsNew docs, merge commits instead of squash. | +| **✅ Fixed** | Web UI typecheck was passing while silently checking less — Nuxt 4.6.1, vue-router ^5.4.0, vue-tsc 3.3, vitest 5. Lockfile regenerated under npm 10 to match Dockerfile pin. | + +Full account: [docs/WhatsNew-v10.5.3.md](docs/WhatsNew-v10.5.3.md). + +--- + ### 🆕 What's new in v10.5.2 A browser front end for the debate workflow, Redis actually reachable from inside the server, Docker @@ -260,7 +281,7 @@ Images are published on every `v*` tag for `linux/amd64` **and** `linux/arm64`: ```bash curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml -export DELIBERA_VERSION=10.5.2 # pin a release; omit to track :latest +export DELIBERA_VERSION=10.5.3 # pin a release; omit to track :latest docker compose -f docker-compose.hub.yml up -d ``` @@ -268,22 +289,20 @@ docker compose -f docker-compose.hub.yml up -d > published port binds to `127.0.0.1`. Put an authenticating reverse proxy in front before exposing > this stack to any network you do not control. -#### 🌐 Web UI on GitHub Pages - -The same UI is deployed statically to on every merge to -`main`. +#### 🌐 Project website on GitHub Pages -A static export has **no server**, so the UI's same-origin BFF route does not exist there — it needs -an absolute API origin, and that server must allow the Pages origin via CORS: - -```yaml -environment: - DELIBERA_CORS_ORIGIN: https://techbuzzz.github.io -``` +The project site is published at on every merge to +`main`. It renders this repository's own Markdown — the guides in `docs/`, the release notes, the +measured-behaviour report, the changelog, and the debate templates parsed from +`Delibera.Core.cs` — so a page cannot advertise a version, a feature or a number the code does +not have. See [`site/README.md`](site/README.md). -CORS is **off by default** and stays off unless you list origins. That is deliberate: the missing -header is what stops a random web page from reading this API, and the Web UI in the container does -not need it. `Wildcard + AllowCredentials` is rejected at startup with a message naming the key. +The Web UI itself is **not** deployed there — it ships in the +[`techbuzzz/delibera-webui`](https://hub.docker.com/r/techbuzzz/delibera-webui) container image and +is meant to run next to `Delibera.Server`, which provides the same-origin BFF route +`/api/delibera/**` the UI depends on. A static export has no Nitro server and therefore no BFF, +so it would need a separate CORS-enabled API origin — a deliberate owner decision, not something +the Pages workflow configures silently. #### 📥 From source @@ -944,7 +963,7 @@ docker compose -f docker-compose.hub.yml up -d ``` Pulls `techbuzzz/delibera-server` and `techbuzzz/delibera-webui` from Docker Hub. Set -`DELIBERA_VERSION=10.5.2` to pin a specific release. +`DELIBERA_VERSION=10.5.3` to pin a specific release. ### Web UI diff --git a/dockerhub/delibera-server.md b/dockerhub/delibera-server.md index ce43bce..ef78b7e 100644 --- a/dockerhub/delibera-server.md +++ b/dockerhub/delibera-server.md @@ -24,7 +24,7 @@ Or bring up the full stack — server, Web UI, Ollama, Qdrant and Redis: ```bash curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml -export DELIBERA_VERSION=10.5.2 +export DELIBERA_VERSION=10.5.3 docker compose -f docker-compose.hub.yml up -d ``` @@ -94,7 +94,7 @@ than silently degrading, because a deployment that quietly fell back would look ## Tags -- `10.5.2`, `10.5` — pinned releases +- `10.5.3`, `10.5` — pinned releases - `latest` — newest release ## Links diff --git a/dockerhub/delibera-webui.md b/dockerhub/delibera-webui.md index 8ef70e7..23c3ff7 100644 --- a/dockerhub/delibera-webui.md +++ b/dockerhub/delibera-webui.md @@ -25,7 +25,7 @@ Or bring up the full stack: ```bash curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml -export DELIBERA_VERSION=10.5.2 +export DELIBERA_VERSION=10.5.3 docker compose -f docker-compose.hub.yml up -d ``` @@ -43,20 +43,12 @@ authenticating reverse proxy in front before exposing this stack to any network --- -## Static build (GitHub Pages) +## GitHub Pages -The same app is published as a static site at - on every merge to `main`. - -A static export has **no Nitro server**, so the `/api/delibera/**` BFF route does not exist there. -Set `NUXT_PUBLIC_DELIBERA_API_BASE` to a reachable server origin to give the static build an API — -and that server must allow the Pages origin via CORS, which `Delibera.Server` does not do by -default. Without it the page deploys and renders, but its requests 404; that is a UI preview, not -an install. - -Two build details worth knowing if you fork it: Nuxt 4 emits to `.output/public`, not `dist`, and -the prerender routes must be listed explicitly in `nuxt.config.ts` — otherwise the export contains -only the `200.html`/`404.html` SPA fallbacks and deploys as a blank page. +The project landing page lives at . The Web UI itself is +**not** deployed there — it ships in this image and is meant to run next to +[`delibera-server`](https://hub.docker.com/r/techbuzzz/delibera-server), which provides the +same-origin BFF route `/api/delibera/**`. --- @@ -91,7 +83,7 @@ the debate. ## Tags -- `10.5.2`, `10.5` — pinned releases +- `10.5.3`, `10.5` — pinned releases - `latest` — newest release ## Links diff --git a/docs/NET10-Upgrade.md b/docs/NET10-Upgrade.md index c6bee4d..0543ca9 100644 --- a/docs/NET10-Upgrade.md +++ b/docs/NET10-Upgrade.md @@ -39,18 +39,49 @@ The changes were made in the files: ### ⚠️ Important note about "C# 15" -At the time of the upgrade, the GA release **.NET SDK 10.0.301** is installed, in which the Roslyn -compiler supports language versions up to and including **14.0**, while the next one (the future -C# 15) is available **only** through the `preview` value. Specifying `15.0` -directly results in a compilation error: +At the time of the original upgrade, the installed SDK was **10.0.301**, whose Roslyn compiler +supports language versions up to and including **14.0**; the next one (the future C# 15) was +available **only** through the `preview` value. Specifying `15.0` +directly resulted in a compilation error: ``` error CS1617: Invalid option '15.0' for /langversion ``` -Therefore `preview` is used — this enables the maximum available set of -language features of the next version (C# 15). When an SDK with official `15.0` support is released, -the value can be replaced with `15.0` without any other changes to the code. +Therefore `preview` is used. When an SDK with official `15.0` support +is released, the value can be replaced with `15.0`. + +#### What `preview` actually buys, measured + +`LangVersion=preview` means different things on different SDKs, because the feature set comes from +the **compiler**, not from the target framework. Two SDKs of the same major band are not +equivalent here: + +| SDK | Roslyn | Stable language level | C# 15 features present | +|-----|--------|----------------------|------------------------| +| 9.0.318 | 4.14 | C# 13 | none | +| 10.0.112 | 5.0 | C# 14 | none | +| **10.0.401** | **5.9** | C# 14 | collection expression arguments, labeled `break`/`continue`, extension indexers | + +`global.json` now pins `10.0.401` (`rollForward: latestFeature`) so a machine holding only +10.0.112 cannot silently compile this codebase without the features it is written against. + +The gate was run by compiling each feature against `net10.0`, not by reading release notes: + +| Feature | Compiles on net10.0? | Note | +|---------|----------------------|------| +| Collection expression arguments — `[with(capacity: n), …]` | **yes** | used on the hot paths | +| Labeled `break` / `continue` | **yes** | ergonomics only; no hot-path candidate | +| Extension indexers | **yes** | no candidate in this codebase | +| Union types | **no** | needs `System.Runtime.CompilerServices.IUnion` | +| Closed hierarchies | **no** | needs `System.Runtime.CompilerServices.IsClosedTypeAttribute` | + +Union types and closed hierarchies are blocked by the **BCL**, not by the compiler: `IUnion` and +`IsClosedTypeAttribute` ship with **.NET 11**, so on `net10.0` the compiler rejects them with +`error CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined` and +`error CS0656: Missing compiler required member '…IsClosedTypeAttribute..ctor'` respectively. +They are therefore deferred to the `net11.0` retarget, together with the runtime work that +retarget unlocks (Runtime Async, JIT improvements, `System.Text.Json` union/JSONL support). --- diff --git a/docs/ReleaseProcess.md b/docs/ReleaseProcess.md index 0ca06fa..6dff9cc 100644 --- a/docs/ReleaseProcess.md +++ b/docs/ReleaseProcess.md @@ -37,8 +37,15 @@ Then release notes: ### 2. Open a PR and merge it CI runs three gates on the PR: `build & test`, `build (verify only)`, `webui tests`. Wait for all -three. Squash-merge — the repo squash-merges, so a feature branch's SHAs never become ancestors -of `main`. +three. **Merge with a merge commit** — not squash. + +Squash was what made branch handling expensive: the squashed PR's commits are not ancestors of +`main`, so `git merge-base --is-ancestor main` cannot answer whether the work shipped, +and merging `main` back into a long-lived feature branch produces a disconnected merge rather +than a fast-forward. A merge commit keeps the ancestry, which makes the back-merge ordinary and +the ancestry check meaningful. `git log --first-parent main` still reads as the release list. + +Delete the branch after merging. It is never reopened: a new change starts from a fresh branch. ### 3. Verify before tagging @@ -85,6 +92,28 @@ the tell: a wrong token takes about a second and says `unauthorized`. ## After tagging +### Approve the publish + +Both publish workflows run their publish job inside a GitHub environment — `nuget-prod` and +`dockerhub-prod`. A tag push queues the run and it **waits** at that job for approval, once +`Required reviewers` is enabled on the environment. + +To approve: **Actions → the run → the waiting job → Review deployments → Approve and deploy.** + +This is a separate layer from the tag gate, and it is the one that actually protects the +registries. `if: startsWith(github.ref, 'refs/tags/v')` already stops a merge to `main` from +publishing anything — but it does nothing about a mistyped tag, or about anyone able to push a +`v*` tag. That path overwrites `latest` on a registry consumers pull from, and re-tagging does +not undo it. + +Enabling required reviewers is a repository setting, not a workflow change: +**Settings → Environments → `nuget-prod` / `dockerhub-prod` → Required reviewers.** Until then +the environment only scopes secrets, the deployment URL and the history. + +The `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` secrets can be moved from repository scope into +`dockerhub-prod` at the same time — the job already runs inside that environment, so they stop +being readable by every other workflow. + ### Check what actually landed Green pipelines are not proof that something was published: @@ -99,20 +128,21 @@ green. That is the failure mode `--skip-duplicate` creates. ### Backfilling a release page -Normally nothing to do: a merge to `main` refreshes the release page for the newest `v*` tag -reachable from `main`, so the page picks up the header and the WhatsNew document without a -handful of clicks. +Nothing happens on a merge any more. `release` is tag-only, so the page is written once when the +tag lands and is not rewritten by unrelated commits afterwards — which matters, because a merge +that happened to touch the header or the WhatsNew document used to silently edit an already +published release. -Run **Actions → release → Run workflow** only to repair a *specific* tag — for instance to rebuild -the body of a release whose page was created by hand. It creates or updates; it never duplicates. +**Actions → release → Run workflow** repairs a page: name the tag in the `tag` input to rebuild +that release, or leave it empty to refresh the newest reachable one. It creates or updates; it +never duplicates. -That workflow distinguishes two situations on purpose, and the difference is what keeps a routine -merge from reddening `main`: +The workflow still distinguishes two situations on purpose: | Trigger | Behaviour on a tag/`` mismatch | |---|---| | `v*` tag pushed, or dispatch naming a tag | **fails** — that tag exists to publish something specific | -| merge to `main` | **exits 0** — an unreleased version bump is a normal state, not an error | +| dispatch with no tag (sync mode) | **exits 0** — an unreleased version bump is a normal state, not an error | ### Backfilling Docker Hub cards @@ -175,58 +205,40 @@ curl -s -o /dev/null -w '%{http_code}\n' https://techbuzzz.github.io/Delibera/ A 404 there, served by GitHub.com itself, means nothing has ever been deployed to this Pages site. Only the Source dropdown in Settings says whether it is configured to accept deploys. -`pages.yml` deploys the Web UI as a **static** export on every merge to `main`. Two consequences: +`pages.yml` builds the **Next.js site** in `site/` and publishes the static export in `site/out` +on every merge to `main`. The site is a view over this repository: the guides and release notes in +`docs/`, `CHANGELOG.md`, and the debate templates in `DebateTemplate.cs`. Nothing is copied into a +second location, so a page cannot advertise a version, a feature or a measurement the code does not +have. + +The version is read from `Delibera.Core.csproj` at build time — the project `publish-nuget.yml` +actually packs. A tag can be pushed before the package reaches nuget.org and the changelog is edited +by hand, so neither is a trustworthy source. If the version cannot be resolved the build fails +rather than publishing a placeholder. -- A static export has **no Nitro server**, so the `/api/delibera/**` BFF route does not exist - there. The browser needs an absolute origin via `NUXT_PUBLIC_DELIBERA_API_BASE`, and that - origin must allow the Pages origin via CORS — which `Delibera.Server` does not do by default. - Unset, the site deploys and its requests 404: a UI preview, not an install. -- Nuxt 4 emits to `.output/public`, **not** `dist`. GitHub's own sample workflow still uploads - `./dist` because it predates Nuxt 4. +The Web UI is **not** deployed to Pages. It ships in the `techbuzzz/delibera-webui` container image +and is meant to run next to `Delibera.Server`, which provides the same-origin BFF route +`/api/delibera/**`. A static export has no Nitro server and therefore no BFF, so the UI would need +a separate CORS-enabled API origin — a deliberate owner decision, not something the Pages workflow +should configure silently. ### Reproduce the Pages build locally -`pages.yml` runs steps that PR CI does not: `nuxt generate`, and two assertions about the export. -That gap is worth closing by hand before every Pages-related change, because none of it can fail -a pull request: +The workflow runs `npm ci`, then `next build` with `PAGES_BASE_PATH` set to the base path +`actions/configure-pages` computes for this repo (`/Delibera`). Set the same variable locally or the +export will differ from production: ```bash -cd src/Delibera.WebUI +cd site npm ci -npm run generate # 6 routes -> .output/public -test -f .output/public/index.html # the gate in pages.yml -test -f .output/public/debates/new/index.html # prerender.routes actually took - -# the origin must be BAKED IN, not merely configured -NUXT_PUBLIC_DELIBERA_API_BASE=https://example.test npm run generate -grep -rqF 'https://example.test' .output/public # 0 hits = the whole feature is dead +PAGES_BASE_PATH=/Delibera npm run build # writes out/ +npm run verify # every internal link, asset and anchor resolves ``` -That last `grep` is the check that matters. `NUXT_PUBLIC_*` is substituted only for names declared -under `runtimeConfig.public`; an undeclared one is dropped silently, the build stays green, and the -deployed site quietly calls its own origin. It has already happened once here. - -Note `npm run build` (not `generate`) before `npm test` — the SSE suite loads -`.output/server/index.mjs`, which a static export does not produce, and reports a suite failure -that looks like a broken test rather than a missing artefact. - -### Verify base-path runs from PowerShell, not Git Bash - -Git Bash rewrites an environment value that looks like a POSIX path when it exports it to a child -process. `NUXT_APP_BASE_URL=/Delibera npm run generate` reaches the build as -`C:/Program Files/Git/Delibera`. - -That malformed base produces exactly the output a real Nuxt bug would: `.output/public/index` -and `.output/public/debates/new` containing `Redirecting...` instead of documents, no `index.html` -anywhere, and a green build. I spent a cycle "fixing" nuxt.config.ts on the strength of that. - -Set these variables from PowerShell, where the value passes through untouched, before believing -anything about a base-path build: - -```powershell -$env:NUXT_APP_BASE_URL = '/Delibera' -npm run generate -``` +`npm run verify` is not decoration. A green `next build` proves the pages compiled, not that they +link to each other, and three failure modes only appear on the live site: an internal href with no +emitted file behind it, a missing `/Delibera` prefix, and a `#fragment` pointing at a renamed +heading. `verify` fails the deploy on all three. --- diff --git a/docs/TASKS/README.md b/docs/TASKS/README.md index c85abb1..8127767 100644 --- a/docs/TASKS/README.md +++ b/docs/TASKS/README.md @@ -216,7 +216,7 @@ from the per-member admission check, whose state machine was allocated on every Method: `.bench/PerfCheck` — a fake provider returning instantly, three members, three rounds, long deterministic responses, baseline checkouts supplied as git worktrees via `-p:DeliberaCorePath=`. Full numbers and caveats in -[performance-measurements.md](../performance-measurements.md#8-framework-overhead-and-retrieval-measured-across-checkouts-v1051). +[performance-measurements.md](../performance-measurements.md#2-debate-overhead). ### W5-09 · indexing produces the same corpus regardless of run count diff --git a/docs/WhatsNew-v10.5.3.md b/docs/WhatsNew-v10.5.3.md new file mode 100644 index 0000000..b045a83 --- /dev/null +++ b/docs/WhatsNew-v10.5.3.md @@ -0,0 +1,340 @@ +# Delibera v10.5.3 — What's New + +> **Release type: feature and fix release. No breaking changes.** No public API changed, no member +> was removed from an existing interface, and no signature changed. Everything new is opt-in. + +This release adds an opt-in CORS policy for deliberately static browser clients, a project website +on GitHub Pages, accessibility improvements and behaviour fixes in the Web UI, three server-side +performance fixes, and a BuiltIn constants refactor that gives every magic string a single home. + +--- + +## Table of contents + +- [The project website](#the-project-website) +- [Opt-in CORS](#opt-in-cors) +- [BuiltIn constants](#builtin-constants) +- [Server performance](#server-performance) +- [Web UI fixes and accessibility](#web-ui-fixes-and-accessibility) +- [CI and release process](#ci-and-release-process) +- [Dependency pins](#dependency-pins) +- [Verification](#verification) +- [Upgrading](#upgrading) + +--- + +## The project website + +GitHub Pages now deploys a **project website** — a Next.js static export living in `site/` — +rather than the Nuxt Web UI. + +**Why the change.** The Web UI is meant to ship in the `techbuzzz/delibera-webui` container image +and run next to `Delibera.Server`, which provides the same-origin BFF route `/api/delibera/**`. A +static export has no Nitro server and therefore no BFF, so the deployed UI could only reach an API +via a separate CORS-enabled origin — a deliberate owner decision, not something the Pages workflow +should configure silently. + +The site is a **view over this repository, not a copy of it**. Every page is rendered at build time +from content that already lives here: + +| Page | Source | +| --- | --- | +| `/` | version from `Delibera.Core.csproj`, release cards from `docs/WhatsNew-*.md` | +| `/docs//` | every Markdown file under `docs/`, at any depth | +| `/blog//` | `docs/WhatsNew-*.md` | +| `/measurements/` | `docs/performance-measurements.md` | +| `/templates/` | `src/Delibera.Core/Templates/DebateTemplate.cs` | +| `/changelog/` | `CHANGELOG.md`, split per release | + +Consequences worth stating, because they are the point: + +- **The version cannot be wrong.** It is read from the csproj that `publish-nuget.yml` actually + packs — not from a tag (which can be pushed before the package reaches nuget.org) and not from + this changelog (which is edited by hand). If it cannot be resolved, the build fails. +- **The templates page cannot be stale.** Roles, strategy, chairman stance and round count are + extracted from `DebateTemplate.cs` at build time. If the parse finds nothing, the page says so + rather than showing a list that no longer matches the package. +- **Adding a Markdown file adds a page.** No route list to update, and every existing cross-link + to that file starts resolving. Repository-relative links are rewritten to site routes; targets + the site does not serve become absolute GitHub links. +- **A broken cross-link fails the deploy.** `scripts/verify-export.mjs` walks the export and fails + on an internal href with no file behind it, a missing `/Delibera` prefix, or a `#fragment` + pointing at a renamed heading. All three pass a green `next build` and 404 in production — + GitHub Pages is case-sensitive while every local check is not. + +Code blocks are highlighted with Shiki using GitHub's own TextMate grammars, in both light and +dark themes, following `prefers-color-scheme`. The site ships no CSS framework and no client-side +JavaScript beyond the Next.js runtime. + +Live at: + +--- + +## Opt-in CORS + +`Delibera.Server` gained a named CORS policy, registered only when origins are explicitly +configured. + +**Off by default, and the default is the point.** The Web UI reaches the API through its own +same-origin BFF route and never needs CORS. The absent header is also what stops an arbitrary web +page from *reading* a response — a cross-origin `fetch` still fires the request without it, it just +cannot see the answer. Turning this on globally would have added a browser-side CSRF vector to an +API that is unauthenticated, unthrottled, and spends real credits per debate. + +```yaml +environment: + DELIBERA_CORS_ORIGIN: https://example.com +``` + +```csharp +// Program.cs — opt-in, wired only when AllowedOrigins is non-empty +builder.Services.AddDeliberaServer(builder.Configuration); +// AddDeliberaCors is called inside AddDeliberaServer; UseDeliberaCors only emits headers when a +// policy was registered. +``` + +Design decisions: + +- **Origins are matched exactly.** There is no `SetIsOriginAllowed(_ => true)` path. This API is + unauthenticated; "any origin" would let any page on the internet spend credits through someone + else's browser. +- **`AllowCredentials` together with `*` is rejected at startup** with a message naming the config + key, rather than by the framework with one that names nothing. +- **`X-Correlation-Id` is exposed** so a cross-origin caller can find its entry in the server log. + +13 new tests: 8 asserting what gets registered, and 5 driving the real pipeline over HTTP — that a +default host emits no `Access-Control-Allow-Origin`, that a configured origin gets its own origin +echoed back rather than a wildcard, that an unlisted origin gets nothing, that the preflight is +answered, and that `X-Correlation-Id` is exposed. + +--- + +## BuiltIn constants + +`src/Delibera.Core/BuiltIn.cs` — a single source of truth for the magic strings that previously +drifted between `Delibera.Core`, `Delibera.Redis`, `Delibera.Server` and consumer applications. + +Grouping is by domain: + +| Group | Contents | +| --- | --- | +| `HttpHeaders` | `X-Correlation-Id`, `X-Tenant-Id`, `X-Accel-Buffering` | +| `HttpHeaderValues` | `text/event-stream`, `no-cache`, `application/json`, `Bearer` | +| `SseEvents` | `debate-round`, `debate-complete`, `debate-error`, `heartbeat`, `reconnect` | +| `ConfigSections` | `Delibera`, `Delibera:Server`, `Delibera:Redis`, `Delibera:Rag` | +| `Models` | Default model names and context-window patterns | +| `Endpoints` | Route prefixes and well-known paths | +| `Timeouts` | Debate, SSE heartbeat, and provider timeouts | +| `Redis` | Stream and key prefixes | +| `HttpClients` | Named `IHttpClientFactory` client names | +| `Roles` | Chairman, Knowledge Keeper, Operator display names | +| `MetadataKeys` | `HttpContext.Items` keys | +| `Json` | Serializer options preset names | +| `Paths` | Default file and directory paths | + +Every constant is a `const` (or `static readonly` for non-primitive types) so the value is inlined +at compile time — there is no runtime cost and no allocation. + +**SSE event names are a contract with the Web UI front-end.** Changing a value here requires a +matching change in `Delibera.WebUI`. + +--- + +## Server performance + +Three per-request costs removed: + +1. **`ValidationFilter` cached validator type resolution.** It called + `typeof(IValidator<>).MakeGenericType(arg.GetType())` for every argument of every request. + Building a closed generic type allocates a runtime type; the mapping is now cached per argument + type. +2. **`RedisDebateOrchestrator` reused the injected `ConnectionMultiplexer`.** It opened its own, + bypassing the singleton the host registers — a second TCP pool, handshake and reconnect timers + for the life of the process, for a class whose own documentation claimed it shared the connection + with `RedisDebateCache`. It now reuses the injected multiplexer when there is one, and still + opens its own when constructed outside DI. +3. **The SSE writer assembled each frame once.** It emitted each frame as six writes plus a flush, + and re-encoded the event name from string to UTF-8 on every event. A frame is now assembled once + in the response pipe and flushed once, with event names as `u8` literals. + +Per-frame flushing is unchanged deliberately: a buffering proxy once passed every functional test +here while delivering rounds 150–200 seconds late. + +Regression tests: + +- `Validator_Type_Is_Resolved_Once_Per_Contract_Not_Per_Request` fails if the cached validator type + disappears. +- `Round_Is_Flushed_While_The_Debate_Is_Still_Running` withholds the terminal event and requires + the round to reach the wire within three seconds; it was verified to fail when the per-frame + flush is removed. + +`docs/performance-measurements.md` records the before/after measurement. + +--- + +## Web UI fixes and accessibility + +### The proxy kept upstream SSE connections open after the browser left + +Every upstream call ran with `timeout: 0`, because a debate legitimately takes 150–200 s and its +stream stays open longer still, so nothing else ever ended those requests. Closing the tab, +navigating away or letting `EventSource` reconnect left the server-side connection running for the +remainder of the debate — one leaked stream per piece of user navigation. + +The proxy now aborts its own fetch when the response closes before it was fully written, and the +resulting read error is swallowed rather than thrown: a client hanging up is not an upstream fault +worth reporting to nobody. + +**Measured on the test fixture:** the upstream socket closes **6 ms** after the client disconnects +with the fix and **2699 ms** without it — the second being the moment the fake upstream ends its own +response, which is exactly the point: a connection the proxy never cancelled closes anyway. + +### The typecheck was passing while silently checking less + +`npm run typecheck` printed `[Vue] Load plugin failed: vue-router/volar/sfc-route-blocks` on every +run and exited **0**, so CI reported green. Two defects sat behind the one message: `vue-tsc@2` +cannot load a `vue-router@5` plugin at all, and once that is upgraded the plugin must also be +resolvable from the project root, which it is not while it only exists nested under +`node_modules/nuxt`. + +Nuxt is now `4.6.1`, `vue-router@^5.4.0` matching it, `vue-tsc@3.3`, `vitest@5`, +`@nuxt/test-utils@4`, and the check is clean for the first time. + +### Three Web UI behaviours that were silently wrong + +- **Cancel reported itself without saying anything.** Both branches of `onCancel` called + `loadRecord()`, so "cancelled" and "had already finished" produced identical silence and an + unexplained status change. They now say which one happened. +- **Changing a filter did not re-query.** The selects only changed what was *selected*; the list + kept rendering the previous filter's results under controls claiming the new one, and paging was + never reset. A filter change now reloads from page 1. +- **`npm test` failed on a clean checkout.** The SSE suite runs the built Nitro server, and CI only + passed because its `build` step happens to run first. `pretest` now builds. + +### Accessibility + +- **Rounds arriving over SSE were invisible to a screen reader.** The count in the heading changed + silently, and the reader was most likely on the verdict panel. A polite live region now announces + each round — polite, because rounds arrive as a steady drip and interrupting whatever is being + read for each one would make the page unusable rather than live. +- **Every tab was titled "Delibera — AI council".** Each route now titles itself, and a debate + names itself by its label once loaded. +- **Buttons, inputs and rows gained hover states and a visible `:focus-visible` ring**, the browser + default being near-invisible against these dark surfaces. +- **The error page now shows the status message** and clears the error through `clearError` + instead of relying on a link that renders without the router state it needs. + +### Proxy stays on `h3`, and says why + +Nuxt 4.6 added `nuxt/server`, a server-agnostic surface that re-declares the request helpers +against Nuxt's own `RequestEvent` and omits the response-writing helpers, so a non-Nitro builder +can satisfy it. The proxy route is exactly the case it cannot serve: forwarding an upstream body +verbatim is `sendWebResponse`, streaming SSE chunk by chunk is `sendStream`, and Nuxt's +`RequestEvent` is not assignable to h3's `H3Event` — mixing the surfaces fails typecheck, not at +runtime. The imports stay on `h3` with the reasoning recorded at the import. + +### Lockfile fix + +The Web UI `package-lock.json` was out of sync with `package.json` under npm 10 — `unplugin@3.4.0` +peer dependency entries were missing. Regenerated with `npm@10.9.4` matching the Dockerfile pin. +The Docker build (`npm ci`) now succeeds. + +--- + +## CI and release process + +- **Docker Hub publish is behind an environment** (`dockerhub-prod`), like NuGet (`nuget-prod`). + Both require approval before pushing to the registries. +- **Release pages are built from `docs/WhatsNew-*.md`** — the release workflow creates the GitHub + Release page from this document and an auto-generated PR list, and fails loudly on a + tag/version mismatch. +- **Merge commits instead of squash.** Squash was what made branch handling expensive: the squashed + PR's commits are not ancestors of `main`, so `git merge-base --is-ancestor main` cannot + answer whether the work shipped, and merging `main` back into a long-lived feature branch produces + a disconnected merge rather than a fast-forward. +- **`dockerhub-metadata` workflow** PATCHes the Docker Hub repository description, overview and + categories on every merge to `main` that touches `dockerhub/**`. + +See `docs/ReleaseProcess.md` for the full release runbook. + +--- + +## Dependency pins + +### Node is pinned to an exact patch + +`nuxt@4.6.1` declares `engines.node: ^22.22.3 || ^24.15.0 || >=26.0.0`, so the floating +`node:22-alpine` and `.nvmrc: 22` that worked by accident stop working the moment the tag moves +against us. Both are now `22.23.3`, the Dockerfile resolving one `ARG NODE_VERSION` across its +three stages. + +Node 24 ("Krypton") satisfies the range too and is the natural move before 22 ("Jod") leaves +maintenance in April 2027 — but that also moves the bundled npm from 10.x to 11.x, which deserves +its own change. + +### npm is pinned in the Dockerfile + +The base image ships npm 10.9.9 while a developer workstation commonly has a different major, and +the two resolve some transitive dependencies differently. The Dockerfile pins `npm@10.9.4` so the +image build is reproducible. Bump this together with the `.nvmrc` / local npm, never on its own. + +--- + +## Verification + +Verified on this tree: + +```bash +dotnet build Delibera.slnx -c Release -warnaserror # 0 errors / 0 warnings +dotnet test Delibera.slnx -c Release # Core + Server + gRPC tests passing +dotnet pack src/Delibera.Core/Delibera.Core.csproj -c Release # emits 10.5.3.nupkg + +# Web UI +cd src/Delibera.WebUI +npm ci --no-audit --no-fund # succeeds (lockfile in sync under npm 10) +npm test # API contract + SSE streaming tests +npm run typecheck # clean — no plugin load failures + +# Site +cd site +npm ci +PAGES_BASE_PATH=/Delibera npm run build +npm run verify # every internal link, asset and anchor resolves +``` + +--- + +## Upgrading + +**No migration is needed.** Nothing in the public API changed, and no default behaviour changed. + +What is new and opt-in: + +| Feature | How to enable | +| --- | --- | +| CORS for static browser clients | Set `Delibera:Server:Cors:AllowedOrigins` to a list of origins | +| Project website | Already live at | +| BuiltIn constants | Reference `Delibera.Core.BuiltIn.*` instead of copying literals | +| Node 22.23.3 | Update `.nvmrc` and the Dockerfile `ARG NODE_VERSION` together | + +Set `DELIBERA_VERSION=10.5.3` to pin the Docker images; omit it to track `:latest`. + +--- + +## What's next + +- **Node 24 ("Krypton")** — the natural move before 22 ("Jod") leaves maintenance in April 2027. + Moves the bundled npm from 10.x to 11.x, which is a separate change to make on purpose. +- **C# 15 union types and closed hierarchies** — deferred to the `net11.0` retarget; they need + `System.Runtime.CompilerServices.IUnion` and `IsClosedTypeAttribute`, which ship with .NET 11. + The full feature gate is recorded in `docs/NET10-Upgrade.md`. +- **Distributed turn execution** — `DebateWorkerService.ProcessMessageAsync` remains a deliberate + no-op. Redis today buys shared state and round events, not horizontal debate scaling. + +--- + +1. **Pull.** `git pull` on `main`; the tag is `v10.5.3`. +2. **NuGet.** `Delibera.Core`, `Delibera.Server` and `Delibera.Redis` are all `10.5.3`. +3. **Docker.** `techbuzzz/delibera-server:10.5.3` and `techbuzzz/delibera-webui:10.5.3`, + `linux/amd64` + `linux/arm64`. +4. **Site.** shows `10.5.3`. diff --git a/docs/performance-measurements.md b/docs/performance-measurements.md index 02e9a1e..c4bce68 100644 --- a/docs/performance-measurements.md +++ b/docs/performance-measurements.md @@ -200,7 +200,7 @@ Everything here is a stopwatch reading against a running container. | Check | Surface | ms | |---|---|---:| -| `GET /api/v1/health` | REST | 110 | +| `GET /health` | REST | 110 | | `GET /templates` (5 registered) | REST | 36 | | `POST /corpora` | REST | 37 | | `POST /corpora/{id}/documents` (1 real chunk written) | RAG ingest | 321 | @@ -307,110 +307,6 @@ polluting caller would misreport its rejection rate. --- -## 7. Vector indexing, measured against live stores (v10.5.1) - -The measurement round above found that indexing was not idempotent: three runs over the same document -left 72 points for 24 unique chunks, because `BaseRagProvider` assigned `Guid.NewGuid()` to every chunk -and both stores use the point id as an upsert key. That finding was measured with an in-memory stand-in, -so the fix was re-measured against real Qdrant and real PostgreSQL + pgvector. - -| Check | Qdrant | pgvector | -|---|---|---| -| 3 index runs of a 6-chunk document | **6 points** (10.5.0 behaviour: 18) | **6 points** | -| Editing the document, then re-indexing | still 6 — the chunk overwrote its own point | still 6 | -| Every returned point id parses as a GUID | yes | yes | -| Legacy random-id points, then re-index | 6 → 12 — duplicates survive | 6 → 12 | - -Two of these rows carry weight beyond the fix itself. - -**Point ids must parse.** Both stores silently substitute a fresh GUID when the supplied id fails to -parse (`Guid.TryParse(...) ? ... : Guid.NewGuid()`). A malformed deterministic id would therefore -reintroduce exactly the duplication the fix removes, quietly. - -**Re-indexing does not migrate.** The last row is why the release notes tell users to *delete* the -collection rather than re-index it: points written by 10.5.0 and earlier carry random ids that no -re-index can replace. `IndexFileAsync` logs a warning when the pre-existing point count exceeds what -the current document contributes, which is the signature of an unmigrated collection. - -### The pgvector backend had never run - -Running the same suite against PostgreSQL exposed an unrelated and more serious defect: `PgVectorStore` -could not write or search at all. It bound the embedding with `AddWithValue(new Vector(...))`, which -takes `object` and boxes the `Vector`, leaving Npgsql with no type to map: - -``` -InvalidCastException: Writing values of 'Pgvector.Vector' is not supported for parameters having no -NpgsqlDbType or DataTypeName -``` - -Declaring `NpgsqlDbType.Unknown` fails the same way — `UseVector()` registers a mapping only for the -Npgsql version the `Pgvector` package was built against, and this project pairs `Pgvector` 0.3.2 with -Npgsql 10. The unit tests covered the store through fakes, so the defect survived every release that -claimed pgvector support. It is fixed in 10.5.1 by sending the vector as pgvector's text form with a -`::vector` cast, which any pgvector version understands. - -Method: `.bench/FeatureBench`, experiment E6 — see -[FEATURE-BENCH-FINDINGS.md](../.bench/FEATURE-BENCH-FINDINGS.md). Embeddings for this experiment are a -local deterministic hash, not a model: the question is whether each store upserts on the supplied id, and -a remote embedder would add cost and a second failure mode to an experiment about neither. - ---- - -## 8. Framework overhead and retrieval, measured across checkouts (v10.5.1) - -The numbers above come from live cloud models, so they cannot be re-run cheaply and they cannot -isolate framework cost — model latency swamps it. Two questions were therefore asked separately, -with a fake provider returning instantly and with real vector stores. - -### 8.1 Did 10.5.1 make the pipeline slower? - -Three checkouts, same harness, same workload: three members, three rounds, chairman verdict, -deterministic long responses. - -| Checkout | Median | p95 | Allocations, first debate | -|---|---|---|---| -| v10.5.0 (`1297e38`) | 0.634 ms | 1.22 ms | 3 735 640 B | -| v10.5.1, accounting unguarded (`978f4cb`) | **1.404 ms** | 1.96 ms | **5 034 080 B** | -| v10.5.1, accounting guarded (this tree) | 0.679 ms | 1.41 ms | 3 736 000 B | - -**It did — and it was found and fixed.** Recording a member call was unconditional: every call -concatenated the system and user prompt and ran the token counter over both the prompt and the -response. The prompt grows every round as history accumulates, so a debate paid a scanning cost -quadratic in its own length, and then discarded it — the executor publishes `CostEstimate` only when a -gate, limiter or price registry is configured. Cost was **+121%** on the median and **+35%** on -allocations, for every user whether or not they cared about cost. - -The fix is a predicate: accounting runs only when something consumes it (`DebateExecutionOptions -.CostTrackingEnabled`). A second pass removed an `async ValueTask` from the per-member admission -check, whose state machine was allocated on every call even when it returned immediately. Wall time -returned to baseline and allocations to within 360 bytes of 10.5.0 — **+0.01%**. - -Repeated 80-iteration runs put the two within each other's noise band (0.52–0.60 ms against -0.56–0.66 ms), so the honest statement is: **no measurable regression remains, and none of the -sub-millisecond differences here should be treated as a signal.** Absolute times vary with what else -the machine is doing; the allocation figure is the deterministic one. - -### 8.2 What the indexing fix actually buys - -Same corpus, same queries, against live Qdrant — varying only how many index runs had happened. - -| | v10.5.0 | v10.5.1 | -|---|---|---| -| 1 index run | 120 points | 120 points | -| 3 index runs | **360 points** | **120 points** | -| Search median, after 3 runs | 0.968 ms | **0.873 ms** | - -Three index runs now leave exactly as many points as one. The retrieval win at this corpus size is -real but modest (~10%): hundreds of vectors are index-resident, so scanning three times as many buys -back very little time. **The larger effect is ranking, not latency, and it is not measured here** — -duplicates crowd the top-k, which is the dilution the 10.5.0 round reported. Measuring that needs a -recall/precision comparison, which this harness does not do. - -Method: `.bench/PerfCheck`, built against a checkout via `-p:DeliberaCorePath=`; baseline checkouts -were git worktrees at `1297e38` and `978f4cb`. - ---- - ## Reproducing | harness | what it measures | diff --git a/global.json b/global.json new file mode 100644 index 0000000..ee6c4c9 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/img/delibera-square-1024x1024-clear.png b/img/delibera-square-1024x1024-clear.png new file mode 100644 index 0000000..d830c20 Binary files /dev/null and b/img/delibera-square-1024x1024-clear.png differ diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..4a7178f --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,20 @@ +# dependencies +node_modules/ + +# next.js build output +.next/ +out/ + +# Brand images copied from ../img by scripts/sync-assets.mjs - never committed twice. +public/img/ + +# typescript +next-env.d.ts +*.tsbuildinfo + +# local env files +.env*.local + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..af10b81 --- /dev/null +++ b/site/README.md @@ -0,0 +1,137 @@ +# Delibera website + +The GitHub Pages site: + +A [Next.js](https://nextjs.org) app configured for `output: 'export'`, following +[nextjs/deploy-github-pages](https://github.com/nextjs/deploy-github-pages). It is a *view* +over files that already live in this repository — there is no CMS and no second copy of any +content. + +## What it publishes + +| Page | Source | +| --- | --- | +| `/` | Version from `Delibera.Core.csproj`, release cards from `docs/WhatsNew-*.md` | +| `/docs//` | Every Markdown file under `docs/`, at any depth | +| `/blog//` | `docs/WhatsNew-*.md` | +| `/measurements/` | `docs/performance-measurements.md` | +| `/templates/` | `src/Delibera.Core/Templates/DebateTemplate.cs` | +| `/changelog/` | `CHANGELOG.md`, split per release | + +Content is read at build time. Adding a Markdown file to `docs/` adds a page — there is no +route list to update. + +### Nested documents + +The docs route is a catch-all, so the directory layout is mirrored in the URL: + +| File | Page | +| --- | --- | +| `docs/QuickStart.md` | `/docs/quickstart/` | +| `docs/TASKS/README.md` | `/docs/tasks/` | +| `docs/TASKS/W1-correctness.md` | `/docs/tasks/w1-correctness/` | +| `docs/scope/I-00-project-vision.md` | `/docs/scope/i-00-project-vision/` | + +A directory's `README.md` collapses to the directory itself, which is why the work log is +reachable at `/docs/tasks/` — the URL the previous landing page already pointed at. + +Excluded, with the reason in `listDocPages()`: release notes (they are under `/blog/`), +`performance-measurements.md` (under `/measurements/`), `v10.2.6.md` (a duplicate of its +release note), and `docs/habr/` — a draft being written for Habr that still carries editorial +notes about which headline to use. Same category as `.articles/`, which is gitignored. + +### Why the version comes from the csproj + +`src/Delibera.Core/Delibera.Core.csproj` is the input to `dotnet pack` in +`publish-nuget.yml`. A git tag can be pushed before the package reaches nuget.org, and +CHANGELOG.md is edited by hand; either can disagree with what consumers can actually install. +The csproj cannot. If the version cannot be resolved, the build **fails** rather than +publishing a page that advertises an unpublished version. + +### Why the templates page parses C# + +The roles, strategy, chairman stance and round count on `/templates/` are extracted from +`DebateTemplate.cs` at build time. A hand-written list would be a second place to update on +every template change — and this project documents cases where a claim drifted from what the +code actually did. If the parse finds no templates, the page says so instead of showing a +stale list. + +## Design notes + +### Header logo + +The header uses `img/delibera-square-1024x1024.png` cropped to the scales mark +(`x 244..778, y 170..581`). The full lockup — mark, `DELIBERA` wordmark and tagline — is 662px +tall in a 1024px canvas, so at header size the wordmark caps render at about 3px. The hero keeps +the wide horizontal lockup, where there is room for it. + +The crop is a wrapper with negative offsets rather than a rewritten PNG: the Pages build runs on +ubuntu, where `System.Drawing` does not exist, so cropping at `sync-assets` time would work on a +Windows dev machine and fail in CI. + +All three brand PNGs are `Format24bppRgb` with an opaque near-white background (253–254) — there is +no alpha channel to blend. The mark is dark navy, so the logo chip carries an explicit white plate +in both themes; on a dark header the mark would otherwise be invisible. + +## Commands + +```bash +npm ci +npm run dev # http://localhost:3000 +npm run build # static export to out/ +npm run verify # check every link/asset/anchor in out/ +npm run typecheck +``` + +`npm run build` writes to `out/`, which is what `actions/upload-pages-artifact` publishes. + +## Deployment + +`.github/workflows/pages.yml` runs on every merge to `main`: + +1. `npm ci` in `site/` +2. `actions/configure-pages` computes the base path (`/Delibera`) and passes it as + `PAGES_BASE_PATH` +3. `npm run build` +4. `npm run verify` — fails the deploy on a broken internal link +5. upload `site/out` and deploy + +### Why `npm run verify` exists + +A green `next build` proves the pages compiled, not that they link to each other. These +failures only show up on the live site: + +- an internal href that no emitted file matches; +- a missing `/Delibera` prefix (Next adds `basePath` to `` and its own chunks, but + **not** to a raw `` or a metadata URL — see `lib/paths.ts`); +- a `#fragment` pointing at a heading that was renamed; +- a route still written in the old mixed case. GitHub Pages is case-sensitive; Windows and + local dev servers are not, so this passes locally and 404s in production. + +## Layout + +``` +app/ routes: /, /docs, /blog, /measurements, /templates, /changelog +components/ layout shell, markdown body, table of contents +lib/content.ts reads docs/, CHANGELOG.md, the csproj, DebateTemplate.cs +lib/markdown.ts marked + Shiki, heading anchors, repository-link rewriting +lib/routes.ts repo path -> site route (single source of truth) +lib/paths.ts basePath helpers for raw and metadata URLs +scripts/ asset sync, export verification +``` + +## Adding a page + +Drop a Markdown file into `docs/`. It appears in `/docs//`, in the sitemap, and every +existing cross-link to it starts resolving. + +Release notes are special-cased by filename: `docs/WhatsNew-v10.5.3.md` publishes to +`/blog/10.5.3/`. A `-RU` suffix gets its own URL rather than overwriting the English page. + +## Not deployed here + +The Nuxt Web UI (`src/Delibera.WebUI`) ships as the container image +`techbuzzz/delibera-webui` and runs next to `Delibera.Server`, which provides the +same-origin BFF route `/api/delibera/**`. A static export has no Nitro server and therefore +no BFF — the UI would need a separate CORS-enabled API origin, which is an owner decision +rather than something a deploy workflow should configure silently. \ No newline at end of file diff --git a/site/app/blog/[slug]/page.tsx b/site/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..d9db6e1 --- /dev/null +++ b/site/app/blog/[slug]/page.tsx @@ -0,0 +1,75 @@ +import { notFound } from 'next/navigation' +import type { Metadata } from 'next' +import { getVersion, listReleaseNotes, loadDoc } from '@/lib/content' +import { buildRouteIndex, releaseRoute } from '@/lib/routes' +import { renderMarkdown } from '@/lib/markdown' +import { Breadcrumbs, PageHead, PageShell } from '@/components/layout' +import { DocLayout } from '@/components/markdown' +import { githubBlob } from '@/lib/site' + +export const dynamicParams = false + +export async function generateStaticParams() { + const notes = await listReleaseNotes() + return notes.map((note) => ({ slug: note.slug })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + const note = (await listReleaseNotes()).find((candidate) => candidate.slug === slug) + + if (!note) return { title: 'Not found' } + + return { + title: note.title, + description: note.description, + alternates: { canonical: releaseRoute(note.slug) }, + } +} + +export default async function ReleaseNotePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const version = await getVersion() + + const note = (await listReleaseNotes()).find((candidate) => candidate.slug === slug) + if (!note) notFound() + + const { markdown } = await loadDoc(note.source) + const [routes] = await Promise.all([buildRouteIndex()]) + const { html, headings } = await renderMarkdown(markdown, { + source: note.source, + routes, + stripPreamble: true, + }) + + return ( + + {/* No visible description: the release note opens with that same sentence. The + extracted description is still used for the page metadata above. */} + + + + + +
+

+ Source:{' '} + + {note.source} + {' '} + on GitHub. +

+
+
+ ) +} \ No newline at end of file diff --git a/site/app/blog/page.tsx b/site/app/blog/page.tsx new file mode 100644 index 0000000..914b3f3 --- /dev/null +++ b/site/app/blog/page.tsx @@ -0,0 +1,82 @@ +import Link from 'next/link' +import type { Metadata } from 'next' +import { getVersion, getReleases, byVersionDescending, isReleased, listReleaseNotes } from '@/lib/content' +import { PageShell, PageHead } from '@/components/layout' +import { Reveal } from '@/components/reveal' +import { releaseRoute, normalisedReleaseSlug } from '@/lib/routes' + +export const metadata: Metadata = { + title: 'Release notes', + description: 'What changed in every Delibera release, newest first.', +} + +/** + * Release-note index. + * + * Reads `docs/WhatsNew-*.md` directly rather than the changelog: these are the long-form + * notes written for each release, and their descriptions come from the documents themselves. + * The changelog has its own page. + */ +export default async function BlogIndexPage() { + const version = await getVersion() + const notes = await listReleaseNotes() + const releases = (await getReleases()).sort(byVersionDescending) + + const latestReleased = releases.find(isReleased) + + return ( + + + +
+
+ {latestReleased ? ( + +
+ 🎉 +
+
+ Latest release +
+
+ v{latestReleased.version} + {latestReleased.date ? ` — ${latestReleased.date}` : null} +
+
+
+
+ ) : null} + +
+ {notes.map((note, index) => ( + +
+

+ {note.title} +

+ {note.description ?

{note.description}

: null} +
+ v{normalisedReleaseSlug(note.slug)} + {note.lang === 'ru' ? 🇷🇺 RU : 🇬🇧 EN} +
+
+
+ ))} +
+
+
+
+ ) +} diff --git a/site/app/changelog/page.tsx b/site/app/changelog/page.tsx new file mode 100644 index 0000000..097cf40 --- /dev/null +++ b/site/app/changelog/page.tsx @@ -0,0 +1,132 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { + getVersion, + getReleases, + byVersionDescending, + isReleased, + listReleaseNotes, +} from '@/lib/content' +import { PageShell, PageHead } from '@/components/layout' +import { buildRouteIndex, releaseRoute, normalisedReleaseSlug, CHANGELOG_ROUTE } from '@/lib/routes' +import { renderMarkdown } from '@/lib/markdown' +import { githubBlob } from '@/lib/site' +import { ChangelogTimeline } from '@/components/changelog-timeline' + +export const metadata: Metadata = { + title: 'Changelog', + description: 'Complete change history for Delibera, from 10.1.0 onwards.', +} + +/** + * The changelog, split per release so each version gets an anchor instead of one + * 23 KB wall of text. + * + * Read straight from `CHANGELOG.md` and rendered through the same pipeline as the guides, so + * a `## [10.5.2]` section here is byte-identical to the one in the repository file. + */ +export default async function ChangelogPage() { + const version = await getVersion() + const releases = (await getReleases()).sort(byVersionDescending) + const [routes] = await Promise.all([buildRouteIndex()]) + + const rendered = await Promise.all( + releases.map(async (release) => { + const { html } = await renderMarkdown(release.body, { + source: 'CHANGELOG.md', + routes, + }) + + return { release, html } + }), + ) + + const published = rendered.filter(({ release }) => isReleased(release)).length + + // Not every released version has a long-form note: the changelog goes back to 10.1.0, + // while `docs/WhatsNew-*.md` starts at 10.2.6. Link only where a note actually exists — + // otherwise the card is a 404 on a page that exists to be the complete record. + const notedVersions = new Set( + (await listReleaseNotes()).map((note) => normalisedReleaseSlug(note.slug)), + ) + + // Data for the timeline component + const timelineItems = rendered.map(({ release }) => ({ + version: release.version, + date: release.date, + slug: normalisedReleaseSlug(release.version), + hasNotes: notedVersions.has(release.version), + isUnreleased: release.version === 'unreleased', + })) + + return ( + + + + + +
+
+ {rendered.map(({ release, html }) => ( +
+

+ {release.version === 'unreleased' ? ( + + Unreleased + + ) : ( + <>v{release.version} + )} + {release.date ? ( + + {' '} + — {release.date} + + ) : null} +

+ {notedVersions.has(release.version) ? ( +

+ {/* `Link`, not `a`: only the router-aware component gets `basePath` + applied automatically. A raw anchor would point at /blog/... and 404 + on the deployed site under /Delibera. */} + + → Read the long-form notes for v{release.version} + +

+ ) : null} +
+
+ ))} +
+ + +
+
+ ) +} diff --git a/site/app/docs/[...slug]/page.tsx b/site/app/docs/[...slug]/page.tsx new file mode 100644 index 0000000..2efac67 --- /dev/null +++ b/site/app/docs/[...slug]/page.tsx @@ -0,0 +1,110 @@ +import { notFound } from 'next/navigation' +import type { Metadata } from 'next' +import { getVersion, listDocPages, loadDoc } from '@/lib/content' +import { buildRouteIndex } from '@/lib/routes' +import { renderMarkdown } from '@/lib/markdown' +import { Breadcrumbs, PageHead, PageShell } from '@/components/layout' +import { DocLayout } from '@/components/markdown' +import { githubBlob } from '@/lib/site' + +/** + * One page per Markdown file under `docs/`, at any depth. + * + * The catch-all `[...slug]` is what lets `docs/TASKS/W1-correctness.md` publish to + * `/docs/tasks/w1-correctness/` and `docs/TASKS/README.md` publish to `/docs/tasks/`. + * A single-segment `[slug]` route cannot express that, and the work log was previously + * reachable from the landing page. + * + * `dynamicParams = false` is deliberate: under `output: 'export'` an unlisted parameter would + * have no server to render it on request, and Next.js fails the build rather than emitting a + * page that 404s. The document list is therefore the contract — a new file under `docs/` must + * be published to appear on the site, which is exactly the guarantee that stops a link from + * silently pointing at something that was never exported. + */ +export const dynamicParams = false + +export async function generateStaticParams() { + const pages = await listDocPages() + return pages.map((page) => ({ slug: page.slug.split('/') })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string[] }> +}): Promise { + const { slug } = await params + const wanted = slug.join('/') + const page = (await listDocPages()).find((candidate) => candidate.slug === wanted) + + if (!page) return { title: 'Not found' } + + return { + title: page.title, + description: page.description, + alternates: { canonical: `/docs/${page.slug}/` }, + } +} + +export default async function DocPage({ params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params + const wanted = slug.join('/') + const version = await getVersion() + + const pages = await listDocPages() + const page = pages.find((candidate) => candidate.slug === wanted) + + if (!page) notFound() + + const { markdown } = await loadDoc(page.source) + const [routes] = await Promise.all([buildRouteIndex()]) + const { html, headings } = await renderMarkdown(markdown, { + source: page.source, + routes, + stripPreamble: true, + }) + + // Breadcrumbs mirror the directory: `/docs/tasks/w1-correctness/` yields + // Home / Docs / Work log / W1 — Correctness. + // + // The group label is plain text, not a link to `/docs//`. Only `docs/TASKS/` has an + // index file; `docs/scope/` has none, so a link there would be a 404 on a breadcrumb. + const segments = page.slug.split('/') + const crumbs: Array<{ href?: string; label: string }> = [ + { href: '/', label: 'Home' }, + { href: '/docs/', label: 'Docs' }, + ...(segments.length > 1 ? [{ label: sectionLabel(segments[0] ?? '') }] : []), + { label: page.title }, + ] + + return ( + + {/* No visible description: the document opens with that same sentence. The extracted + description is still used for the page metadata above. */} + + + + + +
+

+ Source:{' '} + + {page.source} + {' '} + on GitHub — edit it there and this page updates on the next deploy. +

+
+
+ ) +} + +/** Directory segment -> the heading the docs index uses for that group. */ +function sectionLabel(segment: string): string { + const labels: Record = { + tasks: 'Work log', + scope: 'Scope', + } + + return labels[segment] ?? segment +} \ No newline at end of file diff --git a/site/app/docs/page.tsx b/site/app/docs/page.tsx new file mode 100644 index 0000000..9a567d5 --- /dev/null +++ b/site/app/docs/page.tsx @@ -0,0 +1,131 @@ +import Link from 'next/link' +import type { Metadata } from 'next' +import { getVersion, listDocPages } from '@/lib/content' +import { PageShell, PageHead } from '@/components/layout' +import { Reveal } from '@/components/reveal' +import { docRoute } from '@/lib/routes' +import { DocsSearch } from '@/components/docs-search' + +export const metadata: Metadata = { + title: 'Documentation', + description: + 'Guides for Delibera: quick start, server hosting, distributed debates, caching, the work log and the release history.', +} + +/** Documents are grouped by the directory they live in; top-level files form their own group. */ +interface Group { + key: string + title: string + hint: string + pages: Awaited> +} + +const GROUP_META: Record = { + guides: { + title: 'Guides', + hint: 'How to use Delibera: setup, hosting, distribution, caching and providers.', + }, + tasks: { + title: 'Work log', + hint: 'Open items by severity, including what is deliberately not fixed. Updated as work lands.', + }, + scope: { + title: 'Scope', + hint: 'Planning notes behind the stabilisation effort — hypotheses, and the breaking-change inventory.', + }, +} + +export default async function DocsIndexPage() { + const version = await getVersion() + const pages = await listDocPages() + + const groups = new Map() + for (const page of pages) { + // Group by the *directory* the file lives in, not by its slug. `docs/TASKS/README.md` + // collapses to the slug `tasks` (a directory with no path separator), and keying on the + // slug would file the work-log index under "Guides" alongside the user documentation. + const relative = page.source.replace(/^docs\//, '') + const inDirectory = relative.includes('/') + const key = inDirectory ? (relative.split('/')[0] ?? 'guides').toLowerCase() : 'guides' + + const group = groups.get(key) ?? { + key, + ...(GROUP_META[key] ?? { title: key, hint: '' }), + pages: [], + } + group.pages.push(page) + groups.set(key, group) + } + + // Guides first, then the supporting material, so the index reads top-down by importance. + const order = ['guides', 'tasks', 'scope'] + const ordered = [...groups.values()].sort( + (a, b) => order.indexOf(a.key) - order.indexOf(b.key), + ) + + // Flatten for search + const allPages = pages.map((page) => ({ + title: page.title, + description: page.description, + href: docRoute(page.slug), + group: page.source.replace(/^docs\//, '').includes('/') + ? (page.source.replace(/^docs\//, '').split('/')[0] ?? 'guides').toLowerCase() + : 'guides', + })) + + return ( + + + + + +
+
+ {ordered.map((group, index) => { + const english = group.pages.filter((page) => page.lang === 'en') + const russian = group.pages.filter((page) => page.lang === 'ru') + + return ( +
+ +

{group.title}

+ {group.hint ?

{group.hint}

: null} +
+ +
+ {english.map((page, pageIndex) => ( + + +
{page.title}
+ {page.description ?
{page.description}
: null} + +
+ ))} +
+ + {russian.length > 0 ? ( + <> +

Русский

+
+ {russian.map((page, pageIndex) => ( + + +
{page.title}
+ {page.description ?
{page.description}
: null} + +
+ ))} +
+ + ) : null} +
+ ) + })} +
+
+
+ ) +} diff --git a/site/app/globals.css b/site/app/globals.css new file mode 100644 index 0000000..1f2714f --- /dev/null +++ b/site/app/globals.css @@ -0,0 +1,1918 @@ +/** + * Site stylesheet. + * + * Hand-written rather than Tailwind: this is a documentation site with six page templates + * and one accent colour, and a build-time-only site has no reason to carry a CSS toolchain. + * Both colour schemes come from `prefers-color-scheme`, so a reader never has to find a + * theme switch. + */ + +:root { + --bg: #ffffff; + --bg-soft: #f6f8fa; + --bg-inset: #eef1f4; + --border: #d0d7de; + --fg: #1f2328; + --fg-dim: #59636e; + --accent: #0969da; + --accent-soft: #ddf4ff; + --accent-glow: rgba(9, 105, 218, 0.15); + --warn-bg: #fff8c5; + --warn-border: #d4a72c; + --warn-fg: #7d4e00; + --radius: 10px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); + --gradient-hero: linear-gradient(135deg, #0969da 0%, #6639ba 50%, #bf3989 100%); + --gradient-subtle: linear-gradient(180deg, var(--bg) 0%, var(--bg-soft) 100%); + --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --wrap: 1200px; + --measure: 74ch; + --header-height: 64px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --bg-soft: #161b22; + --bg-inset: #010409; + --border: #30363d; + --fg: #e6edf3; + --fg-dim: #9198a1; + --accent: #58a6ff; + --accent-soft: #121d2f; + --accent-glow: rgba(88, 166, 255, 0.2); + --warn-bg: #272115; + --warn-border: #9e6a03; + --warn-fg: #e3b341; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); + --gradient-hero: linear-gradient(135deg, #58a6ff 0%, #a371f7 50%, #f778ba 100%); + --gradient-subtle: linear-gradient(180deg, var(--bg) 0%, var(--bg-soft) 100%); + } +} + +/* Light theme override via JS toggle */ +:root[data-theme='light'] { + --bg: #ffffff; + --bg-soft: #f6f8fa; + --bg-inset: #eef1f4; + --border: #d0d7de; + --fg: #1f2328; + --fg-dim: #59636e; + --accent: #0969da; + --accent-soft: #ddf4ff; + --accent-glow: rgba(9, 105, 218, 0.15); + --warn-bg: #fff8c5; + --warn-border: #d4a72c; + --warn-fg: #7d4e00; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); + --gradient-hero: linear-gradient(135deg, #0969da 0%, #6639ba 50%, #bf3989 100%); + --gradient-subtle: linear-gradient(180deg, var(--bg) 0%, var(--bg-soft) 100%); +} + +:root[data-theme='dark'] { + --bg: #0d1117; + --bg-soft: #161b22; + --bg-inset: #010409; + --border: #30363d; + --fg: #e6edf3; + --fg-dim: #9198a1; + --accent: #58a6ff; + --accent-soft: #121d2f; + --accent-glow: rgba(88, 166, 255, 0.2); + --warn-bg: #272115; + --warn-border: #9e6a03; + --warn-fg: #e3b341; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); + --gradient-hero: linear-gradient(135deg, #58a6ff 0%, #a371f7 50%, #f778ba 100%); + --gradient-subtle: linear-gradient(180deg, var(--bg) 0%, var(--bg-soft) 100%); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #0d1117; + --bg-soft: #161b22; + --bg-inset: #010409; + --border: #30363d; + --fg: #e6edf3; + --fg-dim: #9198a1; + --accent: #58a6ff; + --accent-soft: #121d2f; + --accent-glow: rgba(88, 166, 255, 0.2); + --warn-bg: #272115; + --warn-border: #9e6a03; + --warn-fg: #e3b341; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); + --gradient-hero: linear-gradient(135deg, #58a6ff 0%, #a371f7 50%, #f778ba 100%); + --gradient-subtle: linear-gradient(180deg, var(--bg) 0%, var(--bg-soft) 100%); + } +} + +* { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + 24px); +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 16px/1.65 var(--sans); + overflow-wrap: break-word; + transition: background-color 0.3s ease, color 0.3s ease; +} + +a { + color: var(--accent); + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover { + text-decoration: underline; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--accent); + color: #fff; + padding: 10px 16px; + z-index: 100; + border-radius: 0 0 8px 0; +} + +.skip-link:focus { + left: 8px; + top: 8px; +} + +.wrap { + max-width: var(--wrap); + margin: 0 auto; + padding: 0 24px; +} + +.wrap-narrow { + max-width: var(--measure); + margin: 0 auto; + padding: 0 24px; +} + +/* ---------------------------------------------------------------- site header */ + +.site-header { + position: sticky; + top: 0; + z-index: 50; + background: color-mix(in srgb, var(--bg) 85%, transparent); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} + +.site-header.scrolled { + box-shadow: var(--shadow-md); + border-bottom-color: var(--border); +} + +.site-header__inner { + display: flex; + align-items: center; + gap: 20px; + min-height: var(--header-height); +} + +.site-header__brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + color: var(--fg); + font-size: 1rem; + transition: transform 0.2s ease; +} + +.site-header__brand:hover { + text-decoration: none; + transform: scale(1.02); +} + +.site-header__logo { + --logo-source-w: 1024; + --logo-mark-x: 244; + --logo-mark-y: 170; + --logo-mark-w: 535; + --logo-mark-h: 412; + --logo-mark-height: 36px; + + position: relative; + overflow: hidden; + flex: none; + width: calc(var(--logo-mark-w) / var(--logo-mark-h) * var(--logo-mark-height)); + height: var(--logo-mark-height); + border-radius: 8px; + background: #ffffff; + transition: box-shadow 0.3s ease; +} + +.site-header__logo:hover { + box-shadow: 0 0 20px var(--accent-glow); +} + +.site-header__logo img { + position: absolute; + top: calc(var(--logo-mark-y) / var(--logo-mark-h) * var(--logo-mark-height) * -1); + left: calc(var(--logo-mark-x) / var(--logo-mark-h) * var(--logo-mark-height) * -1); + width: calc(var(--logo-source-w) / var(--logo-mark-h) * var(--logo-mark-height)); + height: auto; + max-width: none; +} + +@media (prefers-color-scheme: dark) { + .site-header__logo { + box-shadow: 0 0 0 1px var(--border); + } +} + +[data-theme='dark'] .site-header__logo { + box-shadow: 0 0 0 1px var(--border); +} + +/* Desktop nav */ +.site-nav { + display: flex; + gap: 4px; + margin: 0 auto; + align-items: center; +} + +.site-nav a { + color: var(--fg-dim); + padding: 8px 14px; + border-radius: 8px; + font-size: 0.925rem; + font-weight: 500; + position: relative; + transition: all 0.2s ease; +} + +.site-nav a::after { + content: ''; + position: absolute; + bottom: 4px; + left: 14px; + right: 14px; + height: 2px; + background: var(--accent); + border-radius: 2px; + transform: scaleX(0); + transform-origin: center; + transition: transform 0.25s ease; +} + +.site-nav a:hover { + color: var(--fg); + background: var(--bg-soft); + text-decoration: none; +} + +.site-nav a:hover::after { + transform: scaleX(1); +} + +.site-nav a[aria-current='page'] { + color: var(--accent); + font-weight: 600; + background: var(--accent-soft); +} + +.site-nav a[aria-current='page']::after { + transform: scaleX(1); +} + +/* Theme toggle */ +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); + color: var(--fg-dim); + cursor: pointer; + transition: all 0.2s ease; + margin-left: 8px; +} + +.theme-toggle:hover { + color: var(--fg); + background: var(--bg); + border-color: var(--accent); + transform: scale(1.05); +} + +.theme-toggle svg { + width: 18px; + height: 18px; +} + +/* Header actions (search, theme toggle, mobile menu) */ +.site-header__actions { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +/* Mobile menu button */ +.mobile-menu-btn { + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--fg); + cursor: pointer; + transition: all 0.2s ease; +} + +.mobile-menu-btn:hover { + background: var(--bg-soft); +} + +.mobile-menu-btn svg { + width: 20px; + height: 20px; +} + +/* Mobile nav */ +.mobile-nav { + display: none; + position: fixed; + top: var(--header-height); + left: 0; + right: 0; + bottom: 0; + background: var(--bg); + z-index: 40; + padding: 24px; + flex-direction: column; + gap: 8px; + overflow-y: auto; + transform: translateX(100%); + transition: transform 0.3s ease; +} + +.mobile-nav.open { + display: flex; + transform: translateX(0); +} + +.mobile-nav a { + display: block; + padding: 14px 16px; + border-radius: 10px; + font-size: 1.1rem; + font-weight: 500; + color: var(--fg); + transition: all 0.2s ease; +} + +.mobile-nav a:hover { + background: var(--bg-soft); + text-decoration: none; +} + +.mobile-nav a[aria-current='page'] { + background: var(--accent-soft); + color: var(--accent); +} + +.mobile-nav__footer { + margin-top: auto; + padding-top: 24px; + border-top: 1px solid var(--border); + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +/* Search button */ +.search-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); + color: var(--fg-dim); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.search-btn:hover { + border-color: var(--accent); + color: var(--fg); + background: var(--bg); +} + +.search-btn kbd { + font-family: var(--mono); + font-size: 0.75rem; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); +} + +/* Search modal */ +.search-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 100; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 10vh 20px 20px; + animation: fadeIn 0.2s ease; +} + +.search-modal { + width: 100%; + max-width: 640px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; + animation: slideDown 0.3s ease; +} + +.search-input-wrapper { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.search-input-wrapper svg { + width: 20px; + height: 20px; + color: var(--fg-dim); + flex: none; +} + +.search-input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--fg); + font-size: 1.1rem; + font-family: var(--sans); +} + +.search-input::placeholder { + color: var(--fg-dim); +} + +.search-results { + max-height: 400px; + overflow-y: auto; + padding: 8px; +} + +.search-result { + display: block; + padding: 12px 16px; + border-radius: 8px; + color: var(--fg); + transition: all 0.15s ease; +} + +.search-result:hover, +.search-result.selected { + background: var(--bg-soft); + text-decoration: none; +} + +.search-result.selected { + background: var(--accent-soft); +} + +.search-result__title { + font-weight: 600; + margin-bottom: 4px; +} + +.search-result__desc { + font-size: 0.875rem; + color: var(--fg-dim); + line-height: 1.5; +} + +.search-result__path { + font-size: 0.75rem; + color: var(--fg-dim); + font-family: var(--mono); + margin-top: 4px; +} + +.search-empty { + padding: 32px 20px; + text-align: center; + color: var(--fg-dim); +} + +.search-hint { + padding: 12px 20px; + border-top: 1px solid var(--border); + display: flex; + gap: 16px; + font-size: 0.75rem; + color: var(--fg-dim); +} + +.search-hint kbd { + font-family: var(--mono); + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-soft); +} + +@media (max-width: 900px) { + .site-nav { + display: none; + } + + .search-btn { + display: none; + } + + .mobile-menu-btn { + display: flex; + } + + .site-header__inner { + gap: 12px; + } +} + +/* ------------------------------------------------------------------- sections */ + +main { + min-height: 60vh; +} + +.hero { + padding: 80px 0 64px; + text-align: center; + position: relative; + overflow: hidden; + border-bottom: 1px solid var(--border); +} + +.hero::before { + content: ''; + position: absolute; + top: -50%; + left: 50%; + transform: translateX(-50%); + width: 800px; + height: 800px; + background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%); + pointer-events: none; + animation: pulse 8s ease-in-out infinite; +} + +.hero > .wrap { + position: relative; + z-index: 1; +} + +.hero > .wrap > img { + width: 100%; + max-width: 640px; + height: auto; + filter: drop-shadow(0 20px 40px var(--accent-glow)); + animation: float 6s ease-in-out infinite; +} + +.hero h1 { + font-size: clamp(2.2rem, 5vw, 3.2rem); + margin: 24px 0 16px; + letter-spacing: -0.03em; + line-height: 1.3; + padding-bottom: 0.1em; + background: var(--gradient-hero); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: inline-block; +} + +.hero p { + color: var(--fg-dim); + font-size: 1.15rem; + max-width: 720px; + margin: 0 auto; + line-height: 1.7; +} + +.hero-tags { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + margin-top: 28px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; + margin-top: 32px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 600; + transition: all 0.2s ease; + cursor: pointer; + border: 1px solid transparent; +} + +.btn-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 4px 14px var(--accent-glow); +} + +.btn-primary:hover { + background: color-mix(in srgb, var(--accent) 90%, black); + transform: translateY(-2px); + box-shadow: 0 8px 24px var(--accent-glow); + text-decoration: none; +} + +.btn-secondary { + background: var(--bg); + color: var(--fg); + border-color: var(--border); +} + +.btn-secondary:hover { + background: var(--bg-soft); + border-color: var(--accent); + transform: translateY(-2px); + text-decoration: none; +} + +.btn svg { + width: 18px; + height: 18px; +} + +section.band { + padding: 64px 0; + border-bottom: 1px solid var(--border); +} + +section.band:last-of-type { + border-bottom: 0; +} + +h2.band__title { + font-size: 1.75rem; + margin: 0 0 8px; + letter-spacing: -0.02em; + font-weight: 700; +} + +.band__hint { + color: var(--fg-dim); + margin: 0 0 28px; + font-size: 1rem; + max-width: 640px; +} + +.page-head { + padding: 56px 0 24px; + border-bottom: 1px solid var(--border); + margin-bottom: 40px; + position: relative; + overflow: hidden; +} + +.page-head::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 200px; + background: linear-gradient(180deg, var(--accent-glow) 0%, transparent 100%); + pointer-events: none; +} + +.page-head .wrap { + position: relative; + z-index: 1; +} + +.page-head h1 { + font-size: clamp(1.9rem, 3.5vw, 2.5rem); + margin: 0 0 12px; + letter-spacing: -0.025em; + font-weight: 700; +} + +.page-head p { + color: var(--fg-dim); + margin: 0; + max-width: var(--measure); + font-size: 1.05rem; +} + +.breadcrumbs { + font-size: 0.875rem; + color: var(--fg-dim); + padding: 16px 0 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.breadcrumbs a { + color: var(--fg-dim); + transition: color 0.2s ease; +} + +.breadcrumbs a:hover { + color: var(--accent); +} + +.breadcrumbs span:not(:last-child)::after { + content: '/'; + margin-left: 6px; + opacity: 0.5; +} + +/* ---------------------------------------------------------------------- cards */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.card { + display: block; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px; + color: inherit; + position: relative; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--gradient-hero); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s ease; +} + +.card::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, var(--accent-glow) 0%, transparent 60%); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +a.card:hover { + border-color: var(--accent); + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + text-decoration: none; +} + +a.card:hover::before { + transform: scaleX(1); +} + +a.card:hover::after { + opacity: 1; +} + +.card .t { + font-weight: 700; + margin-bottom: 8px; + font-size: 1.05rem; + display: flex; + align-items: center; + gap: 8px; +} + +.card .t::before { + content: '→'; + opacity: 0; + transform: translateX(-8px); + transition: all 0.3s ease; + color: var(--accent); +} + +a.card:hover .t::before { + opacity: 1; + transform: translateX(0); +} + +.card .d { + color: var(--fg-dim); + font-size: 0.925rem; + line-height: 1.6; +} + +.card .meta { + display: block; + margin-top: 12px; + font-size: 0.8rem; + color: var(--fg-dim); + font-family: var(--mono); +} + +/* --------------------------------------------------------------------- badges */ + +.badges { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + align-items: center; + margin-top: 28px; +} + +.badges a { + display: inline-flex; + align-items: center; + transition: transform 0.2s ease; +} + +.badges img { + display: block; + height: 22px; + width: auto; + max-width: none; +} + +.badges a:hover { + transform: scale(1.05); + text-decoration: none; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--mono); + font-size: 0.8rem; + font-weight: 500; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg-dim); + transition: all 0.2s ease; +} + +a.pill:hover { + border-color: var(--accent); + color: var(--accent); + text-decoration: none; + transform: translateY(-1px); +} + +.pill--accent { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); + box-shadow: 0 0 12px var(--accent-glow); +} + +/* ---------------------------------------------------------------------- prose */ + +.prose { + max-width: var(--measure); +} + +.prose > *:first-child { + margin-top: 0; +} + +.prose h1, +.prose h2, +.prose h3, +.prose h4 { + letter-spacing: -0.01em; + line-height: 1.3; + margin: 40px 0 16px; + scroll-margin-top: calc(var(--header-height) + 24px); + font-weight: 700; +} + +.prose h1 { + font-size: 2rem; +} + +.prose h2 { + font-size: 1.5rem; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.prose h2::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 60px; + height: 3px; + background: var(--gradient-hero); + border-radius: 2px; +} + +.prose h3 { + font-size: 1.2rem; +} + +.prose h4 { + font-size: 1.05rem; + color: var(--fg-dim); +} + +.md-heading { + position: relative; +} + +.md-anchor { + margin-left: 8px; + color: var(--fg-dim); + opacity: 0; + text-decoration: none; + font-weight: 400; + transition: opacity 0.2s ease; +} + +.md-heading:hover .md-anchor, +.md-anchor:focus-visible { + opacity: 1; +} + +.prose p { + margin: 0 0 18px; + line-height: 1.75; +} + +.prose ul, +.prose ol { + margin: 0 0 18px; + padding-left: 28px; +} + +.prose li { + margin-bottom: 8px; + line-height: 1.7; +} + +.prose li > p { + margin-bottom: 8px; +} + +.prose li::marker { + color: var(--accent); +} + +.prose img { + max-width: 100%; + height: auto; + border-radius: var(--radius); +} + +.prose hr { + border: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--border), transparent); + margin: 48px 0; +} + +.prose blockquote { + margin: 0 0 20px; + padding: 16px 20px; + border-left: 4px solid var(--accent); + border-radius: 0 var(--radius) var(--radius) 0; + background: var(--accent-soft); + color: var(--fg); +} + +.prose blockquote p:last-child { + margin-bottom: 0; +} + +.prose :not(pre) > code { + font-family: var(--mono); + font-size: 0.875em; + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); + border-radius: 6px; + padding: 0.15em 0.4em; + color: var(--accent); + font-weight: 500; +} + +.prose table { + display: block; + width: max-content; + max-width: 100%; + overflow-x: auto; + border-collapse: separate; + border-spacing: 0; + margin: 0 0 24px; + font-size: 0.94rem; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.prose th, +.prose td { + border: 1px solid var(--border); + border-top: 0; + border-left: 0; + padding: 12px 16px; + text-align: left; + vertical-align: top; +} + +.prose th:first-child, +.prose td:first-child { + border-left: 0; +} + +.prose th { + background: var(--bg-soft); + font-weight: 600; + white-space: nowrap; +} + +.prose tr:last-child td { + border-bottom: 0; +} + +.prose tr:nth-child(2n) td { + background: color-mix(in srgb, var(--bg-soft) 40%, transparent); +} + +.prose tr:hover td { + background: var(--accent-soft); +} + +/* ---------------------------------------------------------------- code blocks */ + +.md-code { + margin: 0 0 24px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.md-code pre, +.prose pre { + margin: 0; + padding: 20px 24px; + overflow-x: auto; + font-family: var(--mono); + font-size: 0.875rem; + line-height: 1.6; + background: var(--bg-soft); +} + +.prose pre { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.md-code pre code { + background: none; + border: 0; + padding: 0; + font-size: inherit; +} + +.md-code .line { + display: inline-block; + width: 100%; +} + +/* Shiki dual themes */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .shiki, + :root:not([data-theme]) .shiki span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; + } +} + +[data-theme='dark'] .shiki, +[data-theme='dark'] .shiki span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; +} + +/* -------------------------------------------------------------------- callout */ + +.callout { + border: 1px solid var(--warn-border); + border-left-width: 4px; + border-radius: var(--radius); + background: var(--warn-bg); + color: var(--warn-fg); + padding: 20px 24px; + margin: 0 0 28px; + font-size: 0.95rem; + position: relative; + overflow: hidden; +} + +.callout::before { + content: '⚠️'; + position: absolute; + top: 16px; + right: 16px; + font-size: 1.5rem; + opacity: 0.3; +} + +.callout strong { + color: inherit; +} + +.callout > *:last-child { + margin-bottom: 0; +} + +/* --------------------------------------------------------------- table of contents */ + +.toc { + position: sticky; + top: calc(var(--header-height) + 24px); + align-self: start; + max-height: calc(100vh - var(--header-height) - 48px); + overflow-y: auto; + font-size: 0.875rem; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.toc::-webkit-scrollbar { + width: 6px; +} + +.toc::-webkit-scrollbar-track { + background: transparent; +} + +.toc::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.toc h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fg-dim); + margin: 0 0 12px; + font-weight: 600; +} + +.toc ol { + list-style: none; + margin: 0; + padding: 0; +} + +.toc li { + margin: 0 0 2px; + position: relative; +} + +.toc li[data-depth='3'] { + padding-left: 16px; +} + +.toc li[data-depth='4'] { + padding-left: 32px; +} + +.toc li::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 0; + background: var(--accent); + border-radius: 2px; + transition: height 0.2s ease; +} + +.toc li:hover::before, +.toc li.active::before { + height: 60%; +} + +.toc a { + display: block; + color: var(--fg-dim); + padding: 6px 8px; + border-radius: 6px; + transition: all 0.2s ease; + line-height: 1.4; +} + +.toc a:hover { + color: var(--accent); + background: var(--bg); + text-decoration: none; +} + +.toc li.active a { + color: var(--accent); + font-weight: 500; +} + +.doc-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 48px; + align-items: start; + padding-bottom: 80px; +} + +@media (max-width: 1000px) { + .doc-layout { + grid-template-columns: minmax(0, 1fr); + gap: 32px; + } + + .toc { + position: static; + max-height: none; + order: -1; + } +} + +/* ---------------------------------------------------------------- post listing */ + +.post-list { + display: grid; + gap: 16px; +} + +.post { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px 28px; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.post::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--gradient-hero); + transform: scaleY(0); + transform-origin: top; + transition: transform 0.3s ease; +} + +.post:hover { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + box-shadow: var(--shadow-md); + transform: translateX(4px); +} + +.post:hover::before { + transform: scaleY(1); +} + +.post h3 { + margin: 0 0 10px; + font-size: 1.25rem; + font-weight: 700; +} + +.post h3 a { + color: var(--fg); + transition: color 0.2s ease; +} + +.post h3 a:hover { + color: var(--accent); + text-decoration: none; +} + +.post p { + margin: 0 0 14px; + color: var(--fg-dim); + font-size: 0.95rem; + line-height: 1.65; +} + +.post-meta { + display: flex; + gap: 12px; + align-items: center; + font-size: 0.8rem; + color: var(--fg-dim); + font-family: var(--mono); +} + +/* -------------------------------------------------------------------- templates */ + +.tpl-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: 24px; +} + +@media (max-width: 480px) { + .tpl-grid { + grid-template-columns: 1fr; + } +} + +.tpl { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 24px; + background: var(--bg); + transition: all 0.3s ease; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tpl::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 120px; + height: 120px; + background: radial-gradient(circle, var(--accent-glow) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.tpl:hover { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.tpl:hover::after { + opacity: 1; +} + +.tpl h3 { + margin: 0 0 6px; + font-size: 1.15rem; + font-family: var(--mono); + font-weight: 600; + color: var(--accent); +} + +.tpl .role-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0; +} + +.tpl dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 16px; + margin: 0; + font-size: 0.9rem; +} + +.tpl dt { + color: var(--fg-dim); + font-weight: 500; +} + +.tpl dd { + margin: 0; + font-family: var(--mono); + font-size: 0.85rem; +} + +.tpl p.summary { + color: var(--fg-dim); + font-size: 0.925rem; + margin: 16px 0; + line-height: 1.6; +} + +.tpl__code { + margin-top: auto; + padding-top: 16px; +} + +/* ----------------------------------------------------------- code block wrapper */ + +.code-block-wrapper { + position: relative; +} + +.code-block-wrapper pre { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-inset); + overflow-x: auto; + font-family: var(--mono); + font-size: 0.8rem; + line-height: 1.55; +} + +.code-block-wrapper pre code { + background: none; + border: 0; + padding: 0; + font-size: inherit; + color: var(--fg); +} + +.code-copy-btn { + position: absolute; + top: 8px; + right: 8px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--fg-dim); + font-size: 0.75rem; + font-weight: 500; + font-family: var(--sans); + cursor: pointer; + opacity: 0; + transition: all 0.2s ease; + z-index: 1; +} + +.code-block-wrapper:hover .code-copy-btn, +.code-copy-btn:focus-visible { + opacity: 1; +} + +.code-copy-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); +} + +.code-copy-btn.copied { + opacity: 1; + color: #1a7f37; + border-color: #1a7f37; + background: color-mix(in srgb, #1a7f37 10%, var(--bg)); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .code-copy-btn.copied { + color: #3fb950; + border-color: #3fb950; + background: color-mix(in srgb, #3fb950 15%, var(--bg)); + } +} + +[data-theme='dark'] .code-copy-btn.copied { + color: #3fb950; + border-color: #3fb950; + background: color-mix(in srgb, #3fb950 15%, var(--bg)); +} + +/* ---------------------------------------------------------------------- footer */ + +.site-footer { + border-top: 1px solid var(--border); + padding: 48px 0 64px; + color: var(--fg-dim); + font-size: 0.875rem; + background: var(--bg-soft); + position: relative; +} + +.site-footer::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: var(--gradient-hero); + opacity: 0.5; +} + +.site-footer__inner { + display: flex; + flex-wrap: wrap; + gap: 16px 32px; + align-items: center; + justify-content: space-between; +} + +.site-footer nav { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.site-footer a { + color: var(--fg-dim); + transition: color 0.2s ease; + position: relative; +} + +.site-footer a:hover { + color: var(--accent); +} + +/* ------------------------------------------------------------ back to top */ + +.back-to-top { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg-dim); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + visibility: hidden; + transform: translateY(16px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-md); + z-index: 40; +} + +.back-to-top.visible { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.back-to-top:hover { + color: var(--accent); + border-color: var(--accent); + transform: translateY(-4px); + box-shadow: 0 8px 24px var(--accent-glow); +} + +.back-to-top svg { + width: 20px; + height: 20px; +} + +/* ------------------------------------------------------------ reading progress */ + +.reading-progress { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--gradient-hero); + transform-origin: left; + transform: scaleX(0); + z-index: 60; + transition: transform 0.1s linear; +} + +/* ------------------------------------------------------------ animations */ + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 0.5; + transform: translateX(-50%) scale(1); + } + 50% { + opacity: 0.8; + transform: translateX(-50%) scale(1.1); + } +} + +@keyframes gradientShift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +/* Scroll reveal animation */ +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.reveal.visible { + opacity: 1; + transform: translateY(0); +} + +.reveal-delay-1 { transition-delay: 0.1s; } +.reveal-delay-2 { transition-delay: 0.2s; } +.reveal-delay-3 { transition-delay: 0.3s; } +.reveal-delay-4 { transition-delay: 0.4s; } + +/* Feature grid */ +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + margin-top: 32px; +} + +.feature { + padding: 28px; + border-radius: var(--radius-lg); + background: var(--bg); + border: 1px solid var(--border); + transition: all 0.3s ease; +} + +.feature:hover { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + box-shadow: var(--shadow-md); + transform: translateY(-4px); +} + +.feature__icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--accent-soft); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + margin-bottom: 16px; +} + +.feature__title { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 8px; +} + +.feature__desc { + color: var(--fg-dim); + font-size: 0.925rem; + line-height: 1.6; + margin: 0; +} + +/* Stats */ +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 24px; + margin: 32px 0; +} + +.stat { + text-align: center; + padding: 24px; + border-radius: var(--radius-lg); + background: var(--bg); + border: 1px solid var(--border); +} + +.stat__value { + font-size: 2.5rem; + font-weight: 800; + background: var(--gradient-hero); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1; + margin-bottom: 8px; +} + +.stat__label { + font-size: 0.875rem; + color: var(--fg-dim); + font-weight: 500; +} + +/* Code inline in hero */ +.hero pre { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 20px; + text-align: left; + max-width: 560px; + margin: 24px auto 0; + overflow-x: auto; +} + +.hero pre code { + font-family: var(--mono); + font-size: 0.875rem; + color: var(--fg); +} + +/* Copy button for code blocks in rendered markdown (md-code / prose pre) */ +.md-code, +.prose pre { + position: relative; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .reveal { + opacity: 1; + transform: none; + } +} + +/* Print styles */ +@media print { + .site-header, + .site-footer, + .back-to-top, + .reading-progress, + .toc, + .search-btn, + .theme-toggle, + .mobile-menu-btn { + display: none !important; + } + + .hero { + padding: 24px 0; + } + + .hero::before { + display: none; + } + + a { + color: inherit; + text-decoration: underline; + } +} diff --git a/site/app/layout.tsx b/site/app/layout.tsx new file mode 100644 index 0000000..9ed3b41 --- /dev/null +++ b/site/app/layout.tsx @@ -0,0 +1,99 @@ +import type { ReactNode } from 'react' +import { brandImage, siteOrigin } from '@/lib/paths' +import './globals.css' + +// Two different rules apply to the two kinds of URL below, which is the whole trap: +// +// - `openGraph` / `twitter` images are *joined* onto `metadataBase` path-wise by Next, so +// they must NOT already contain the base path — passing it yields `.../Delibera/Delibera/…`. +// - `icons` are emitted verbatim, so they MUST already contain it, or the favicon 404s on +// a Pages deploy where the site is served from a subdirectory. +// +// See scripts/verify-export.mjs, which fails the build on either mistake. +const BANNER = '/img/delibera-horizontal-1920x480.png' +const FAVICON = brandImage('delibera-favicon-1024x1024.png') + +/** + * Site-wide metadata. + * + * `metadataBase` is required for any relative URL (OpenGraph images, canonical links) to + * resolve against an absolute origin — Next.js otherwise emits a warning and drops the tag. + * It is derived from the deployment origin *including* the base path, so a canonical link + * emitted for `/docs/quickstart/` comes out as + * `https://techbuzzz.github.io/Delibera/docs/quickstart/` rather than dropping the prefix. + */ +export const metadata = { + metadataBase: new URL(siteOrigin('/')), + title: { + default: 'Delibera — Thoughtful AI Decisions', + template: '%s · Delibera', + }, + description: + 'Collective decision making through structured AI deliberation — multi-model councils with RAG, pgvector, Knowledge Keeper, MCP tools and live SSE streaming.', + applicationName: 'Delibera', + authors: [{ name: 'Delibera' }], + keywords: [ + 'AI', + 'LLM', + 'multi-agent', + 'deliberation', + '.NET', + 'C#', + 'RAG', + 'pgvector', + 'Qdrant', + 'MCP', + ], + openGraph: { + type: 'website', + siteName: 'Delibera', + title: 'Delibera — Thoughtful AI Decisions', + description: + 'Multi-model AI councils with a chairman, RAG knowledge keeper, MCP tools and live streaming.', + images: [{ url: BANNER, width: 1920, height: 480 }], + }, + twitter: { + card: 'summary_large_image', + title: 'Delibera — Thoughtful AI Decisions', + description: 'Multi-model AI councils with a chairman, RAG, MCP tools and live streaming.', + images: [BANNER], + }, + icons: { + icon: [{ url: FAVICON, type: 'image/png' }], + }, +} + +export const viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0d1117' }, + ], +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + \ No newline at end of file diff --git a/src/Delibera.WebUI/app/components/StatusBadge.vue b/src/Delibera.WebUI/app/components/StatusBadge.vue index 7d6d0aa..93054a1 100644 --- a/src/Delibera.WebUI/app/components/StatusBadge.vue +++ b/src/Delibera.WebUI/app/components/StatusBadge.vue @@ -29,38 +29,38 @@ const tone = computed(() => { font-size: 0.7rem; font-weight: 600; padding: 0.12rem 0.5rem; - border-radius: 999px; + border-radius: var(--radius-pill); border: 1px solid transparent; white-space: nowrap; } .badge--ok { - color: #7ee2a8; - border-color: #2c5c40; - background: #12241a; + color: var(--ok-fg); + border-color: var(--ok-border); + background: var(--ok-bg); } .badge--live { - color: #9fd0ff; - border-color: #2b4a6b; - background: #12202e; + color: var(--live-fg); + border-color: var(--live-border); + background: var(--live-bg); } .badge--pending { - color: #ffd479; - border-color: #6b551f; - background: #241d0f; + color: var(--warn-fg); + border-color: var(--warn-border); + background: var(--warn-bg); } .badge--error { - color: #ff9a9a; - border-color: #6b2c2c; - background: #241212; + color: var(--danger-fg); + border-color: var(--danger-border); + background: var(--danger-bg); } .badge--muted { - color: #9aa3b5; - border-color: #333a48; - background: #1b1f28; + color: var(--neutral-fg); + border-color: var(--border-muted); + background: var(--surface-muted); } \ No newline at end of file diff --git a/src/Delibera.WebUI/app/components/VerdictPanel.vue b/src/Delibera.WebUI/app/components/VerdictPanel.vue index e61e386..2ebcb5f 100644 --- a/src/Delibera.WebUI/app/components/VerdictPanel.vue +++ b/src/Delibera.WebUI/app/components/VerdictPanel.vue @@ -82,9 +82,9 @@ const prettyRaw = computed(() => { \ No newline at end of file diff --git a/src/Delibera.WebUI/app/composables/useApiQuery.ts b/src/Delibera.WebUI/app/composables/useApiQuery.ts new file mode 100644 index 0000000..cf596ec --- /dev/null +++ b/src/Delibera.WebUI/app/composables/useApiQuery.ts @@ -0,0 +1,164 @@ +/** + * `useAsyncData` wrapper for API reads, with the two deployment modes kept explicitly apart. + * + * WHY THIS EXISTS + * Every page used to load its data in `onMounted` with a bare `fetch`. With `ssr: true` that + * means the server returns a shell — heading, no rows — and the reader waits for the bundle + * to arrive before the first request even starts. Moving to `useAsyncData` puts the same data + * in the first HTML response and lets Nuxt carry it across the hydration boundary, which is + * the entire reason to run a server in the first place. + * + * TWO MODES, ONE API + * The app ships two builds from one source, and they must not share a data path: + * + * Container (SSR) — the Nitro server can reach Delibera.Server directly and there is no CORS + * constraint at all, so the SSR pass calls the upstream itself. Going through the BFF would + * mean the server making a loopback request to its own origin, which breaks wherever the + * public host is not resolvable from inside the container — TLS terminated upstream, an + * ingress-only hostname — and buys nothing, since there is no browser involved to protect. + * + * Static export (GitHub Pages) — there is no server to fetch anything, and prerendering runs + * the same SSR pass at BUILD time. Fetching here would bake the debates that existed when + * the site was built into `index.html`, ship them as the payload, and then reuse that + * payload on the client instead of re-fetching: a deployed site that looks populated and is + * frozen at its deploy date. So this mode is pinned to `server: false` and keeps the + * current client-only behaviour. + * + * THE PRERENDER CAVEAT, WHICH IS NOT PAGES-SPECIFIC + * `isStaticExport()` is necessary but not sufficient. `nitro.prerender.routes` is honoured by + * every `nuxt build`, container included: Nitro renders those routes during the build and then + * serves the static file in preference to rendering them again. A page listed there behaves + * exactly like the static export above even though `isStaticExport()` is false — its server + * pass ran on the build machine. So `server` is passed explicitly per call site rather than + * derived, and the two prerendered pages ('/' and '/debates/new') opt out. `/debates/[id]` is + * not prerendered — its ids only exist at runtime — so it takes the full SSR path. + * + * ERRORS ARE DATA, NOT EXCEPTIONS + * `useAsyncData` treats a thrown handler as a request failure: Nuxt renders `error.vue` and the + * page-level alert the UI already has never appears. Every call therefore resolves to a + * `{ value, error }` pair, and the page decides what a failure looks like — an inline alert for + * a 502, the same alert plus an expiry note for a 404. This keeps the existing, deliberate + * error UI intact instead of replacing it with a framework-level error page. + */ + +import type { ParsedApiError } from './useApiError' +import { API_VERSION_PREFIX, buildUpstreamHeaders } from '#shared/deliberaApi' +import { apiRequest, isStaticExport, resolveClientApiBase } from './useDebates' + +export interface ApiQueryResult { + value: T | null + error: ParsedApiError | null +} + +export interface ApiQueryOptions { + /** + * Allow the server to run this handler. Defaults to `true` — except for a static export, + * where it is always off (see the module comment). + */ + server?: boolean +} + +/** Normalises anything thrown into the `ParsedApiError` shape the pages already render. */ +function toParsedError(error: unknown): ParsedApiError { + if ( + typeof error === 'object' && + error !== null && + typeof (error as ParsedApiError).status === 'number' + ) { + return error as ParsedApiError + } + + // A transport failure (API down, DNS, refused connection) never reached `parseApiError`, + // so there is no status and no server wording to show. 502 is what this page would have + // received through the BFF for exactly the same failure. + return { + status: 502, + message: 'The Delibera API is unreachable.', + detail: error instanceof Error ? error.message : String(error), + fieldErrors: {}, + } +} + +/** + * Runs one API read and returns the outcome as a value. + * + * On the server this targets the upstream origin directly, with the tenant header the BFF + * would otherwise have added — `buildUpstreamHeaders` is shared with the proxy precisely so + * the two paths cannot disagree about it. + */ +async function fetchQueryValue(path: string): Promise { + if (import.meta.server) { + const config = useRuntimeConfig() + const base = String(config.deliberaApiBase ?? '').trim().replace(/\/+$/, '') + const headers = buildUpstreamHeaders({}, String(config.public.tenantId)) + + return apiRequest(`${base}${API_VERSION_PREFIX}`, path, { headers }) + } + + return apiRequest(resolveClientApiBase(), path) +} + +/** + * Reactive-friendly wrapper around `useAsyncData`. + * + * `key` may be a getter, which is what makes filtering and pagination work: changing the key + * re-runs the handler automatically, so a page can express "these inputs produce this list" + * instead of hand-wiring a watcher to a manual reload. + * + * Awaited rather than fire-and-forget, so a caller can read the first result straight after + * the call — `onMounted` deciding whether to open a stream must not have to guess whether the + * record has landed yet. This is the documented Nuxt shape for a page-level data read. + */ +export async function useApiQuery( + key: string | (() => string), + path: string | (() => string), + options: ApiQueryOptions = {}, +) { + const server = options.server ?? !isStaticExport() + + const { data, pending, refresh } = await useAsyncData>( + key, + async () => { + try { + const value = await fetchQueryValue(typeof path === 'function' ? path() : path) + return { value, error: null } + } catch (error) { + return { value: null, error: toParsedError(error) } + } + }, + { + server, + // Without this the first render sees `undefined` and every `data.value.x` below it + // needs a guard it should not have needed. + default: () => ({ value: null, error: null }), + + /** + * Reuse the server payload only when it holds data. + * + * This handler resolves on failure instead of throwing, so an SSR render that hit a + * dead API writes `error: {...}` into `__NUXT_DATA__` — and Nuxt would then hydrate from + * that cached result and never refetch. The reader would load the page at the exact + * moment the API was down, see "unreachable", and keep seeing it after the API came + * back, until they thought to press Refresh. Discarding a cached failure costs one extra + * request on that rare path and makes the page self-healing; a cached success is still + * reused, which is the whole point of the payload. + */ + getCachedData: (key, nuxtApp) => { + const cached = nuxtApp.payload.data[key] ?? nuxtApp.static.data[key] + if (cached && typeof cached === 'object' && 'error' in cached && cached.error) { + return undefined + } + return cached + }, + }, + ) + + return { + /** The payload, or null before the first successful load and after a failed one. */ + data: computed(() => data.value?.value ?? null), + /** The failure, or null. Kept separate from `data` so a retry clears it on its own. */ + error: computed(() => data.value?.error ?? null), + pending, + refresh, + } +} \ No newline at end of file diff --git a/src/Delibera.WebUI/app/composables/useDebates.ts b/src/Delibera.WebUI/app/composables/useDebates.ts index a451c53..67ec55b 100644 --- a/src/Delibera.WebUI/app/composables/useDebates.ts +++ b/src/Delibera.WebUI/app/composables/useDebates.ts @@ -5,7 +5,7 @@ * origin. `tenantId` is sent by the BFF from runtime config, not by the browser. * * The one exception is a STATIC build (`nuxt generate`, i.e. GitHub Pages), where no Nitro - * server exists and therefore no BFF route either — see `resolveApiBase()`. + * server exists and therefore no BFF route either — see `resolveClientApiBase()`. */ import type { @@ -14,8 +14,25 @@ import type { DebateRoundDto, TemplateDto, } from '#shared/types/delibera' +import { PROXY_PREFIX } from '#shared/deliberaApi' import { parseApiError } from './useApiError' +/** The configured API origin, or '' when the app is served through its own BFF. */ +function configuredApiOrigin(): string { + return String(import.meta.env.NUXT_PUBLIC_DELIBERA_API_BASE ?? '').trim() +} + +/** + * True for a STATIC build (`nuxt generate`, i.e. GitHub Pages). + * + * The signal is the one `resolveClientApiBase` is already built on: the public API base is + * only ever set for that build, because only that build has no Nitro server to proxy through. + * `useApiQuery` keys its SSR decision off this. + */ +export function isStaticExport(): boolean { + return configuredApiOrigin().length > 0 +} + /** * Resolves the API base, defaulting to the same-origin BFF. * @@ -38,12 +55,22 @@ import { parseApiError } from './useApiError' * Note the tenant header in that case: EventSource cannot set headers, so the BFF no longer * supplies X-Tenant-Id and the API falls back to its `default` tenant. */ -function resolveApiBase(): string { - const configured = String(import.meta.env.NUXT_PUBLIC_DELIBERA_API_BASE ?? '').trim() - return `${configured.replace(/\/+$/, '')}/api/delibera` +export function resolveClientApiBase(): string { + return `${configuredApiOrigin().replace(/\/+$/, '')}${PROXY_PREFIX}` } -async function request(base: string, path: string, init: RequestInit = {}): Promise { +/** + * Performs one API call and normalises a failure into a `ParsedApiError`. + * + * Exported because `useApiQuery` reuses this exact request/error contract on the SSR path. + * A second implementation would eventually parse one of the four upstream error shapes + * differently, and the difference would surface only as a wrong message in production. + */ +export async function apiRequest( + base: string, + path: string, + init: RequestInit = {}, +): Promise { const response = await fetch(`${base}${path}`, { ...init, headers: { @@ -67,7 +94,7 @@ export interface DebateListFilters { } export function useDebates() { - const base = resolveApiBase() + const base = resolveClientApiBase() /** `GET /debates` — returns DebateResponse[]. */ function list(filters: DebateListFilters = {}) { @@ -77,12 +104,12 @@ export function useDebates() { query.set('page', String(filters.page ?? 1)) query.set('pageSize', String(filters.pageSize ?? 20)) - return request(base, `/debates?${query.toString()}`) + return apiRequest(base, `/debates?${query.toString()}`) } /** `GET /debates/{id}` — 404 once the ~30 min record eviction has run. */ function get(id: string) { - return request(base, `/debates/${encodeURIComponent(id)}`) + return apiRequest(base, `/debates/${encodeURIComponent(id)}`) } /** @@ -92,7 +119,7 @@ export function useDebates() { * 150–200 s a debate takes; the async form is what makes the live view possible. */ function create(payload: CreateDebateRequest) { - return request(base, '/debates/async', { + return apiRequest(base, '/debates/async', { method: 'POST', body: JSON.stringify(payload), }) @@ -105,7 +132,7 @@ export function useDebates() { */ async function cancel(id: string): Promise<'cancelled' | 'already-finished'> { try { - await request(base, `/debates/${encodeURIComponent(id)}`, { method: 'DELETE' }) + await apiRequest(base, `/debates/${encodeURIComponent(id)}`, { method: 'DELETE' }) return 'cancelled' } catch (error) { if (typeof error === 'object' && error !== null && (error as { status?: number }).status === 404) { @@ -117,7 +144,7 @@ export function useDebates() { /** `GET /debates/{id}/rounds` — paginated snapshot, for a completed debate. */ function rounds(id: string, page = 1, pageSize = 50) { - return request( + return apiRequest( base, `/debates/${encodeURIComponent(id)}/rounds?page=${page}&pageSize=${pageSize}`, ) @@ -125,7 +152,7 @@ export function useDebates() { /** `GET /templates` — the source of truth for the template picker. Never hardcoded. */ function templates() { - return request(base, '/templates') + return apiRequest(base, '/templates') } /** diff --git a/src/Delibera.WebUI/app/error.vue b/src/Delibera.WebUI/app/error.vue index cf193fa..e000452 100644 --- a/src/Delibera.WebUI/app/error.vue +++ b/src/Delibera.WebUI/app/error.vue @@ -1,8 +1,10 @@ @@ -10,37 +12,68 @@ import type { NuxtError } from '#app' defineProps<{ error: NuxtError }>() + +/** + * Clears the error before navigating. + * + * Nuxt renders this page INSTEAD of the app, so the router state a link would need is not + * there — which is why the escape hatch is an explicit button rather than a NuxtLink that + * would only appear to work. + */ +function goHome(): void { + void clearError({ redirect: '/' }) +} \ No newline at end of file diff --git a/src/Delibera.WebUI/app/pages/debates/[id].vue b/src/Delibera.WebUI/app/pages/debates/[id].vue index 99deb52..8f1386c 100644 --- a/src/Delibera.WebUI/app/pages/debates/[id].vue +++ b/src/Delibera.WebUI/app/pages/debates/[id].vue @@ -10,7 +10,7 @@ record would show "live stream unavailable" indefinitely, which reads as a transport fault rather than the honest "this debate is gone". --> live stream unavailable — polling - live +