From dc0d6d8806e98bf2391b2aa23fa092f5f436a8d0 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 22:22:44 +0200 Subject: [PATCH 01/24] docs(project): add KB management UX implementation plan --- ...8-24-kb-management-ux-audit-and-roadmap.md | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 project/2026-08-24-kb-management-ux-audit-and-roadmap.md diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md new file mode 100644 index 0000000000..4803e0e576 --- /dev/null +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -0,0 +1,343 @@ +# Knowledge Base management UX audit and improvement roadmap + +Status: approved execution plan; implementation in progress + +Date: 2026-08-24 + +Base snapshot: `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1` + +Plan branch: `rs/kb-management-ux` + +Target branch: `v3-ai` + +PR: not created + +Related history: published #5424 (`feat/kb-graph-lifecycle`); no sibling PR changes are in scope. + +## Goal + +Make Knowledge Base management fast to scan and safe to operate. A lecturer should be able to answer three questions immediately: + +1. What is in this Knowledge Base? +2. Is each resource usable by the AI, and what needs attention? +3. How do I add or remove one resource? + +The recommended direction is to make resources the primary workspace, present them in a metadata-rich table, and provide one `+ Add resource` entry point. Website, document upload, and future video support then become choices in one modal instead of separate stacked forms and repeated links. + +## Scope and method + +### In scope + +- The authenticated lecturer flow for the Knowledge Base catalog and detail page. +- Empty and populated states. +- Resource creation, resource search/filtering, inspection, ingestion status, and deletion affordances. +- The surrounding chatbot-binding and knowledge-graph controls insofar as they affect hierarchy and cognitive load. +- English, desktop at 1440×900, and mobile at 390×844. +- A limited keyboard, focus, landmark, heading, and responsive-reflow check. + +German, full WCAG conformance, ingestion correctness, graph quality, chatbot behavior, and production deployment are outside this audit. + +### Evidence + +The primary audit used the running local branch above with the seeded delegated lecturer fixture and `agent-browser` 0.32.0 in headless Chrome. Screenshots are private and remain uncommitted in `/private/tmp/kb-v3-ai-ux-audit-2026-08-24/`. + +| State | Evidence | What it establishes | +| --- | --- | --- | +| Empty catalog | `05-kb-catalog.png` | The catalog has a clear empty state and a single create action. | +| Empty detail | `06-kb-detail.png` | The detail page introduces metrics, two creation surfaces, chatbot bindings, graph controls, and resources in sequence. | +| Populated detail top | `07-kb-detail-populated.png` | The primary add-file and add-link forms occupy the first working area before the resource list. | +| Lower detail and graph | `13-kb-detail-resources-correct.png` | Chatbot and graph configuration appear before the resource workspace; graph controls can expose unavailable cost configuration. | +| Resource controls | `14-kb-detail-resources-visible.png`, `15-kb-resource-card.png` | Search, type/status filters, selection, two status panels, Inspect, and prominent Delete are all available. | +| Resource inspector | `16-resource-inspector.png` | The current inspector exposes useful metadata but duplicates close affordances and needs a stronger modal focus contract. | +| Mobile detail top | `17-kb-detail-mobile-top.png` | The global header does not reflow at 390px; navigation is clipped and the detail starts with four stacked metric cards and an upload panel. | +| Mobile resource workspace | `19-kb-detail-mobile-resources.png`, `20-kb-detail-mobile-resources-header.png` | Resources are reachable only after a long scroll and each card repeats substantial status and action content vertically. | +| Environment issue | `01-kb-page-initial.png` | The first attempt hit a missing local Manage process and returned 502. This is not a product UX finding. The managed audit process was restored before judging the UI. | + +The audit combined one familiarization pass, a second evidence-capture pass, source inspection, and an independent Sol review. No destructive action, successful ingestion, model call, or external provider call was required. A document upload could not be confirmed because the local Blob request failed before confirmation. + +### Existing strengths + +- The catalog title, create action, search, result count, and compact resource/chatbot metrics are easy to identify. +- The detail page uses consistent spacing, labels, borders, and status language. +- Resource search, type/status filters, empty results, selection limits, bulk deletion, inspection, and background-operation messaging are already present. +- URL syntax validation is inline and uses `aria-invalid` and an associated error message for malformed input. +- Destructive operations require confirmation, and Escape cancels the tested dialogs. + +The earlier Knowledge Base work already provides useful data and lifecycle behavior. This plan changes the presentation and entry points first; it does not propose replacing the ledger-only graph lifecycle or inventing a separate Knowledge Base graph-version lifecycle. The core package preserves the current `BLOB`/`URL` resource model, the four existing metrics, explicit post-creation ingestion, and the current server-backed cursor/polling behavior. + +## Findings register + +Severity uses 0–4: 0 cosmetic, 1 minor friction, 2 material friction, 3 serious task/accessibility barrier, 4 blocking or unsafe. + +### F1 — The global header is fixed-width at mobile size + +Severity: 3 + +Evidence: `17-kb-detail-mobile-top.png`; independent Sol pass. + +At 390px the document is wider than the viewport. The navigation visibly truncates at “Resour…”, while Analytics and account controls are offscreen. This makes orientation and access to other areas unreliable and fails the responsive reflow expectation. + +Source anchor: `apps/frontend-manage/src/components/common/Header.tsx:309-330`. + +This is a real usability issue, but it crosses the KB package boundary. It is therefore a proposed separate Manage-shell follow-up, not a prerequisite for the KB detail redesign. + +### F2 — Dialog focus and description behavior is inconsistent + +Severity: 3 + +The tested create, inspector, and delete dialogs did not reliably move focus into the dialog, trap Tab within it, or return focus to the exact opening trigger. After Escape, focus could remain on the background or land on an unrelated control. The inspector also showed duplicate close affordances, and the browser console reported missing dialog description wiring. + +Evidence: `16-resource-inspector.png`; keyboard spot check; independent Sol pass. + +Source anchors: `packages/kb-management/src/components/CreateKnowledgeBaseModal.tsx:43-57`, `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:1197-1213`. + +The add-resource chooser must meet the repository’s existing modal contract. A broad shared-dialog repair belongs in a separate task if the current design-system `Modal` cannot satisfy the contract without cross-application changes. + +### F3 — Client URL validation does not express the server’s safety policy + +Severity: 2 + +The client accepts any HTTP(S) URL, while the server rejects private or local targets. A rejected but syntactically valid URL therefore reaches submission and produces a generic failure instead of a field-level explanation and correction path. The same recovery pattern should be checked for upload failures: preserve the selected file context, explain what failed, and offer retry without forcing the user to rediscover the task. + +This finding comes from the independent partner pass and source inspection; the main audit did not probe private targets. It should be verified against the approved server policy before implementation. Structured policy errors are a separate API/product-contract follow-up, not a reason to expand this UI-only redesign. + +Source anchors: `packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx:9-16,40-56`, `packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx:81-99`, `packages/i18n/messages/en.ts:1551`, and the current URL-policy contract at `docs/domain-model.md:62`. + +### F4 — The primary resource task is buried and each resource card is over-composed + +Severity: 2 + +The detail order is metrics, file upload, URL form, chatbot bindings, graph controls, and only then Resources. On mobile, Resources begins roughly 2,200px down a page of about 3,037px in the captured fixture. Each resource then repeats operation status, serving status, timestamps, Inspect, and a prominent red Delete button. The page asks the user to understand implementation-oriented state before they can scan the resource inventory. + +Evidence: `07-kb-detail-populated.png`, `13-kb-detail-resources-correct.png`, `14-kb-detail-resources-visible.png`, `15-kb-resource-card.png`, `17-kb-detail-mobile-top.png`, `19-kb-detail-mobile-resources.png`, `20-kb-detail-mobile-resources-header.png`. + +Source anchors: `packages/kb-management/src/KnowledgeBaseDetail.tsx:101-201`, `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:1037-1177`. + +### F5 — Website and video resources are not distinguishable in the current model + +Severity: 2 + +The URL form is presented as “Add a link”. A YouTube resource is rendered as the same generic Link type and uses the same icon/filter path as a website. Users cannot scan or filter the inventory by the actual content type they care about. The current model supports only `BLOB` and `URL`, so the core redesign must not relabel existing URLs as Video. Video remains an announced “Coming soon” option until a backend media-type contract exists. + +Evidence: `19-kb-detail-mobile-resources.png`, `20-kb-detail-mobile-resources-header.png`; independent Sol pass. + +Source anchors: `packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx:75-76`, `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:918-923,1229-1232`. + +### F6 — Page structure gives assistive technology weak orientation + +Severity: 2 + +Both the catalog and detail content begin with an `H2`, while the shared layout provides a content `
` rather than a `
` landmark. The visual page can look understandable while the semantic page outline remains weak. + +Source anchors: `packages/kb-management/src/KnowledgeBaseManager.tsx:64-75`, `packages/kb-management/src/KnowledgeBaseDetail.tsx:87-100`, `apps/frontend-manage/src/components/Layout.tsx:84-100`. + +### F7 — Resource creation has multiple competing entry points + +Severity: 2 + +The detail page exposes separate upload and link panels near the top, while the empty resource state repeats two links that jump back to those panels. This creates duplicated navigation and forces a choice between controls that belong to one conceptual action: adding a resource. It also makes the page grow as new resource types are added. + +Evidence: `07-kb-detail-populated.png`, `14-kb-detail-resources-visible.png`; source anchors `packages/kb-management/src/KnowledgeBaseDetail.tsx:185-194` and `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:989-1016`. + +## Review scores + +These are directional scores for this limited audit, not quality gates. F1, F3, and real Video classification remain important follow-ups even though they are outside the core KB-local package. + +- UX heuristics: 6/10. Navigation clarity loses points for F1. Helpful errors loses a point for F3. The “nothing makes me stop and think” heuristic loses a point for the combined F4/F5/F7 presentation and taxonomy friction. +- Refactoring UI: 9/10. Seven of eight diagnostic areas pass in the current implementation; visual hierarchy fails because F4/F5/F7 make the working inventory secondary and visually noisy. +- Accessibility: not a single conformance score. The limited check found reflow risk under WCAG 1.4.10, focus-order risk under 2.4.3, weak landmark/heading structure under 1.3.1 and 2.4.1, and dialog-description warnings. A dedicated accessibility pass remains necessary after redesign. + +## Recommended information architecture + +Use one page with one obvious primary workspace: + +```text +Knowledge Base name and description [ + Add resource ] +Compact summary: resources | storage | pending cleanup | linked consumers + +Resources Search Filters +Semantic data table + +Secondary configuration collapsed by default + Chatbot access expandable + Knowledge graph expandable +``` + +The summary should answer health questions without four large cards. Keep the existing visible-resource, storage, pending-cleanup, and linked-consumer metrics; do not invent an aggregate “AI-ready” metric. Resources should be the first substantial section after identity and summary. Chatbot and graph configuration should remain available but should not compete with the inventory. + +## Improvement roadmap + +The sequence below keeps existing GraphQL mutations and lifecycle behavior wherever possible. It is intentionally presentation-first and should be delivered in small, independently testable slices. + +### R0 — Agree the resource workspace contract + +Addresses: F4, F5, F7; anchors `KnowledgeBaseDetail.tsx:101-201` and `KnowledgeBaseResourceList.tsx:881-1016`. + +Freeze the table columns, current type taxonomy, status vocabulary, action hierarchy, and modal states before changing layout. The current API model supports `BLOB` and `URL`; use File and Link/Web resource as the initial visible types and reserve Video for the unavailable future option. Do not client-sort only the loaded subset or render clickable sortable headers until a server-side ordering contract exists. The repository has `packages/shared-components/src/DataTable.tsx:27`, but it currently owns local sorting/pagination and does not provide the required server-backed load-more, selection, polling, or responsive-column contract. Prefer local semantic design-system table primitives in the KB package unless a narrowly scoped extension is approved. + +The recommended initial row model is: + +| Column | Purpose | +| --- | --- | +| Select | Existing bulk-selection behavior, including the 50-item limit. | +| Type | File or Link/Web resource now, with Video reserved as “Coming soon”. | +| Resource | Title plus filename or hostname. | +| Ingestion | Latest run status, version, and a short actionable failure state. | +| AI availability | Whether the resource is currently available to the AI. | +| Updated | Last resource update. | +| Actions | Inspect as the primary action; overflow menu for ingest/retry/delete. | + +Acceptance: product/design sign-off on these columns, the File/Link labels, the unavailable Video treatment, and the existing explicit post-creation ingestion behavior. Estimate: 0.5–1 day if the existing resource fields are sufficient; no API expansion is part of the core package. + +### R1 — Reorder the detail page and prepare one creation entry point + +Addresses: F4, F7, F6. + +- Add a page-level `H1` and a `
` landmark. +- Keep the back link, name, and description as the identity block. +- Replace the four large metric cards with a compact summary strip. Keep the visible-resource, storage, pending-cleanup, and linked-consumer values; move secondary cleanup/storage detail into an expandable “Capacity details” view if it is not immediately actionable. +- Put Resources directly below the identity and summary. +- Keep the existing creation forms reachable during this transitional slice; remove them and the empty-state jump links only when R3 supplies the working chooser. +- Move Chatbots and Knowledge graph into collapsed secondary sections or a clearly labeled configuration area below the resource workspace. + +Acceptance: at 1440×900 the resource workspace header is visible after the identity block; at 390×844 the resource workspace is reachable in the first meaningful scroll; no creation path disappears before R3 lands. R2 supplies the table rows after this placement slice. Estimate: 1–2 days if the existing sections can be reordered without data-flow changes. + +### R2 — Replace resource cards with a metadata table + +Addresses: F4, F5, F6; source `KnowledgeBaseResourceList.tsx:1037-1177`. + +Use local semantic design-system table primitives in the KB package. Do not reuse `packages/shared-components/src/DataTable.tsx` unchanged: its local sorting/pagination model does not match the resource connection’s server search, cursor load-more, selection, and polling contract. Do not force every desktop column into the 390px layout: preserve Type, Resource, AI availability, and the primary action, and expose the remaining metadata in a row detail/inspector. + +Keep existing search, type/status filters, result count, load-more, selection, bulk delete, polling, and inspector behavior. Reduce the row to one compact status line, use badges or short labels instead of two nested panels, and make Delete an overflow action with confirmation rather than a peer to Inspect. + +Acceptance: a lecturer can scan 20 resources without opening cards; each row exposes type, identity, ingestion state, AI availability, and update time; keyboard selection and bulk deletion retain their current limits; server search, cursor load-more, polling, and active-row fencing remain intact; mobile has no horizontal scroll and does not hide the primary action. Estimate: 2–3 days if local semantic primitives are sufficient, 4–6 days if responsive semantics and selection must be built locally. + +### R3 — Add one `+` resource modal + +Addresses: F5, F7. + +The `+ Add resource` button opens a short choice dialog with three options: + +- **Add website** — available now; opens the existing title/URL form inside the modal or a modal step. +- **Upload document** — available now; opens the existing dropzone and shows accepted formats, size limit, progress, retry, and confirmation states. Cancellation is limited to before an upload ticket is requested unless a separate cleanup contract is approved. +- **Add video** — presented as an announced unavailable “Coming soon” option until the backend contract exists; do not classify or migrate existing URL resources. + +The modal should not auto-ingest unless that behavior is explicitly accepted as the product contract. After creation, show the new row and offer the next action, such as “Ingest”, from the row or inspector. Preserve title and URL/file context on recoverable errors. The upload ticket reserves quota for up to 15 minutes after request (`packages/graphql/src/services/knowledge.ts:1244`); cancellation after that boundary is a lifecycle change, not presentation polish. + +Acceptance: the detail page has one add button and no duplicate creation panels; every current creation path is reachable in at most two deliberate steps; the existing modal contract focuses the first meaningful control, traps focus, and returns focus to `+ Add resource`; the Video option is announced as unavailable; successful creation lands in the table without a page reset and remains `ADDED` until explicit ingestion. The current explicit `ADDED`-then-ingest behavior is anchored at `packages/prisma/src/prisma/schema/knowledge.prisma:9-15,97`, `packages/graphql/src/services/knowledge.ts:1450-1462,1499-1506`, and `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:754-799`. Estimate: 2–3 days if the existing mutations and forms are retained. + +### R4 — De-emphasize secondary graph and chatbot configuration + +Addresses: the remaining hierarchy and option overload observed in `KnowledgeBaseDetail.tsx:195-196`, `KnowledgeBaseChatbotBindings.tsx:96-202`, and `KnowledgeGraphPanel.tsx:353-430`. + +Keep these controls available, but show a compact summary and an explicit “Configure” disclosure rather than a full configuration surface on every detail load. A collapsed graph section must still expose active polling and queued, processing, failed, stale, and cost-review states. When graph cost configuration is unavailable, explain the prerequisite next to the disabled build action and avoid presenting a wall of empty values. + +Keep the approved ledger-only graph-to-question flow. Every `KBGraphBuild` attempt is one durable row in the append-only build ledger; its status and accounting fields settle in place, while `activeGraphBuildId` and `publishedGraphBuildId` remain the only liveness pointers. This slice must not change GraphQL operations, publication rules, pointers, or create another version identity. See `docs/adr/0017-graph-build-ledger-is-canonical.md:15` and `packages/kb-management/src/components/KnowledgeGraphPanel.tsx:216`. + +Acceptance: a user managing resources can complete the common scan/add/inspect path without passing through chatbot or graph controls; configuration remains discoverable; active graph states do not disappear when collapsed; no graph lifecycle behavior changes. Estimate: 1–2 days after R1, with no API changes expected. + +### R5 — KB-local accessibility, regression proof, and documentation + +Addresses: F2 and F6, plus the affected core slices. + +- Add a KB-local page-level `H1` and `
` to both the catalog and detail surfaces without broadening the shared Manage layout change. Include `packages/kb-management/src/KnowledgeBaseManager.tsx` in the affected target files. +- Use the repository’s existing modal contract for the chooser and existing dialogs: focus the first meaningful control, contain Tab, close on Escape, return focus to the exact trigger, provide an accessible name and description, and render one close control. If the shared `Modal` cannot satisfy this without cross-application work, pause and split X1 below. +- Add a reproducible keyboard matrix for catalog create, add-resource choice, website form, upload flow, inspector, and delete confirmation. Screenshots establish layout only; they do not prove focus behavior. +- Add focused Playwright coverage because the current repository has no KB-management Playwright spec. Verify English and German changed-string parity, 1440×900 and 390×844, plus 320 CSS pixels for the narrowest reflow check. This is not a broad localization audit. +- Record durable keyboard results for each dialog in the new spec or its review evidence: focus entry, Tab containment, Escape cancellation, and exact trigger restoration. Screenshots establish layout only. +- Update `docs/frontend-conventions.md` and `.agents/skills/klicker-frontend-ui/SKILL.md` in the implementation PR when the new table/modal pattern becomes a durable repository convention. + +Acceptance: `scrollWidth` equals the viewport width at 390px and 320px; the KB page has one H1 and one main landmark; focus never escapes an open modal and returns to the opening control; table headers, row/action names, status announcements, and chooser descriptions are exposed; focused tests and browser evidence cover empty, populated, active, ready, failed, and modal states. Estimate: 1–2 days for KB-local work, plus time for any separately authorized shared primitive repair. + +### X1/X2 — Separate follow-ups outside the core KB redesign + +- **X1 Manage shell and shared dialog remediation** — F1 and any F2 failure that cannot be solved within the KB package. Repair the global header at small widths and the shared dialog implementation only under a separate task with Manage-wide browser proof. Do not expand the KB PR into an application-shell refactor. +- **X2 URL policy errors and real Video type** — F3 and the real portion of F5. Confirm the server URL policy and stable GraphQL error contract, then add field-level policy errors and a true media-type taxonomy in a separate API/UI change. Until then, keep the current `BLOB`/`URL` model and show Video only as unavailable “Coming soon”. + +## Proposed execution contract + +The user approved R0 and the named S1–S5 local implementation work in this task. The current goal authorizes scoped code edits, local commits, browser verification, and required read-only reviews; it does not authorize X1/X2, pushes, PR updates, merges, deploys, or shell/API follow-ups outside the named runtime checks. + +If the user approves R0 and the implementation package, the main session is the execution orchestrator and the user remains the product/authority boundary owner. Use a new task branch such as `rs/kb-management-ux` from the exact published #5424 snapshot `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1` or the later base the user names. The proposed delivery is one cohesive UX PR targeting `v3-ai`, stacked on #5424 only while that remains the intended integration base; it must not touch sibling question-generation PRs. Do not implement the redesign on `rs/kb-v3-ai-finalization`, which remains the #5424 finalization worktree. + +Approval ratifies R0 and the named S1–S5 local work only; it does not authorize X1/X2, push, merge, deploy, or any graph/API contract change. The current #5424 audit runtime is deliberately kept running for user testing under its separate worktree; do not stop it while this goal uses it for browser verification. At implementation handoff, stopping and verifying the exact implementation runtime is terminal work unless the user explicitly keeps it running; runtime/worktree deletion remains separately authorized. + +### Slices and ownership + +| Slice | Owner | Dependency | Acceptance | +| --- | --- | --- | --- | +| S1 Resource-first page shell | KB implementation executor | R0 contract | Existing four metrics are compacted; Resources precedes configuration; KB-local H1/main; existing creation forms remain reachable until S3. | +| S2 Metadata table | KB implementation executor | S1 | Semantic responsive table preserves server search, cursor load-more, polling, selection, inspector, and actions. | +| S3 Unified add-resource chooser | KB implementation executor | S2 | One `+`; Website and Document use existing mutations; duplicate forms/links are removed only after the chooser works; Video is announced unavailable; creation remains explicit `ADDED` until ingestion. | +| S4 Secondary configuration | Main session | S1–S3 | Chatbot/graph summaries reduce clutter without changing graph polling, pointers, publication rules, or ledger lifecycle. | +| S5 Integrated proof and docs | Main session | S1–S4 | Focused Playwright checks, mandatory browser evidence, package checks, final review, wiki/skill updates, and a clean scoped diff. | +| X1/X2 follow-ups | Separate authorized task | Independent contracts | Manage shell/shared modal or API/media-type changes are planned and verified separately. | + +### Target files and documentation + +The core implementation should stay in the reusable package: + +- `packages/kb-management/src/KnowledgeBaseDetail.tsx` +- `packages/kb-management/src/KnowledgeBaseManager.tsx` +- `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx` +- `packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx` +- `packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx` +- a new KB-local add-resource chooser component if needed +- `project/screenshots/kb-management-ux---.png` and a durable keyboard-results matrix in the implementation evidence +- `packages/i18n/messages/en.ts` and `packages/i18n/messages/de.ts` +- a new focused spec under `playwright/tests/` for KB catalog/detail flows +- `docs/frontend-conventions.md` and `.agents/skills/klicker-frontend-ui/SKILL.md` when the new pattern is durable + +Do not modify `apps/frontend-manage/src/components/common/Header.tsx`, shared modal primitives, GraphQL schemas, Prisma models, or graph lifecycle code in the core package. Those are X1/X2 or explicitly out of scope. + +### Verification, review, and terminal conditions + +Per slice: inspect the diff, run the affected package checks and formatting, run the focused browser/test proof, obtain the applicable simplifier and slice review, update this plan’s Progress section, and create one conventional commit for the slice. After integration, run the final reviewer before presenting the package as complete. + +Required checks include the affected package typechecks, repository formatting/lint checks, focused GraphQL regression coverage, the new KB Playwright spec in English and German, and browser screenshots at 1440×900, 390×844, and 320 CSS pixels. Keep the existing resource lifecycle tests and graph ledger tests unchanged unless a presentation-only selector or assertion requires a narrow update. The boundary owner must confirm any new policy error, media type, upload-ticket cleanup, or shared primitive change before implementation proceeds. + +Pause before implementation or between slices for a material product/API decision, a need to change the current `BLOB`/`URL` contract, a shared Manage-shell or modal change, a graph lifecycle change, a destructive/external action, unavailable required credentials, or a verification blocker that remains after distinct safe approaches. The plan terminates after local checks, browser evidence, required reviews, documentation updates, a clean scoped diff, and stopping/verifying the exact implementation runtime unless explicitly kept running. Publishing, merging, deploying, and runtime/worktree deletion remain separate authorities. + +## Progress + +- [x] Live desktop/mobile audit completed against the exact local snapshot. +- [x] Independent Sol UX audit completed; strengths, findings, and roadmap corrections integrated. +- [x] Planner review completed; scope, existing contracts, table primitive, sequencing, evidence traceability, and execution boundaries corrected. +- [x] Independent Sol final review completed after corrections; roadmap approved with no remaining concerns. +- [x] User approved the KB-local redesign package and implementation goal; S1 is next. + +## Verification plan for implementation + +Each slice should be verified against the same seeded local fixture and a synthetic populated Knowledge Base. The repository currently has no KB-management Playwright spec, so the implementation must add focused coverage rather than treating screenshots as regression protection. + +- Desktop: catalog, empty detail, populated table, add-resource chooser, website form, document upload states, inspector, failed ingestion, and delete confirmation at 1440×900. +- Mobile: the same core states at 390×844 and a narrow reflow check at 320 CSS pixels, with no horizontal scroll and the resource table’s mobile fallback visible. +- Keyboard: open/close/focus return for every dialog, focus containment, table row selection, filters, load-more, primary row action, and confirmation cancellation. Screenshots alone do not prove these behaviors. +- Assistive technology smoke check: one page-level H1, one main landmark, labeled table headers, row/action names, status announcements, and dialog name/description. +- Localization: English and German strings remain in parity for the chooser, table headers, status labels, errors, and unavailable Video option. +- Regression: retain existing GraphQL resource creation, upload confirmation, ingestion, polling, selection limit, bulk delete, chatbot binding, and graph build tests. +- Browser console: no duplicate keys, unknown DOM props, missing dialog description warnings, or handled-error overlays in the reviewed states. + +The implementation should add only the tests that protect consequential observable behavior. A screenshot matrix and focused interaction checks are more valuable here than broad visual snapshots. + +## Open questions and unreachable states + +These should be resolved before implementation crosses the API or product-contract boundary: + +1. Is Video a planned resource type with a known backend shape, or only a future UI option? The recommendation is to show it disabled as “Coming soon” until the contract exists. +2. Does the repository’s design system expose a lower-level semantic table primitive that can be composed locally? `packages/shared-components/src/DataTable.tsx` exists but is not suitable unchanged because it sorts and paginates the loaded client array. The recommendation is to compose local semantic rows with the design-system table primitives and avoid a new dependency. +3. Should creating a resource leave it un-ingested, as the current lifecycle suggests, or should the modal offer an explicit follow-up “Ingest now” action? The recommendation is explicit follow-up, not hidden automatic work. +4. What exact server URL policy should be surfaced to lecturers? The client/server mismatch must be settled before promising a specific validation message. +5. Which graph cost fields are expected to be configured in this environment? The audit saw build controls disabled because cost configuration was unavailable; this was not treated as a product defect without confirmation. + +The local document-upload path could not be confirmed because the Blob request failed before confirmation. No conclusion about the successful upload UX should be drawn from that failure alone. + +## Boundaries + +This plan does not authorize or include: + +- Merging, closing, deploying, or changing the published PR. +- Backend lifecycle changes, new model/provider integrations, or graph-version entities. +- Deleting the disposable local Knowledge Base or its synthetic resources. +- A German localization pass or full WCAG conformance claim. +- Runtime teardown of the current #5424 audit runtime; it remains available for user feedback and testing until separately requested. From 445164220277e91003ef7498d1468b5c3346db5e Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 22:38:57 +0200 Subject: [PATCH 02/24] enhance(kb): prioritize resource workspace --- .../kb-management/src/KnowledgeBaseDetail.tsx | 86 +++++++++---------- .../src/KnowledgeBaseManager.tsx | 8 +- ...8-24-kb-management-ux-audit-and-roadmap.md | 8 +- 3 files changed, 54 insertions(+), 48 deletions(-) diff --git a/packages/kb-management/src/KnowledgeBaseDetail.tsx b/packages/kb-management/src/KnowledgeBaseDetail.tsx index 5a3bbe9f0b..4b69f47b54 100644 --- a/packages/kb-management/src/KnowledgeBaseDetail.tsx +++ b/packages/kb-management/src/KnowledgeBaseDetail.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@apollo/client' import { GetKbDocument } from '@klicker-uzh/graphql/dist/ops' -import { H2, Skeleton, UserNotification } from '@uzh-bf/design-system' +import { H1, Skeleton, UserNotification } from '@uzh-bf/design-system' import { useFormatter, useTranslations } from 'next-intl' import Link from 'next/link' import React, { useState } from 'react' @@ -21,7 +21,7 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { if (loading) { return ( - +
) } if (error || !data?.getKb) { return ( -
+
-
+
) } @@ -84,7 +84,7 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { } return ( -
+
{t('kb.backToList')} -

{data.getKb.name}

+

{data.getKb.name}

{data.getKb.description ? (

{data.getKb.description} @@ -110,34 +110,34 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { > {t('kb.metricsTitle')} -

-
-
+
+
+
{t('kb.metricVisibleResources')} -
-
+ +
{format.number(metrics.visibleResourceCount)} / {format.number(metrics.resourceLimit)} -
-

+ +

{t('kb.metricReservedResources', { count: metrics.reservedResourceCount, })} -

+
-
-
+
+
{t('kb.metricStorage')} -
-
+ +
{formatFileSize(metrics.quotaSizeBytes)} / {formatFileSize(metrics.storageLimitBytes)} -
-

+ +

{metrics.unknownSizeResourceCount > 0 ? t('kb.unknownSizesReserved', { count: metrics.unknownSizeResourceCount, @@ -146,35 +146,35 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { visible: formatFileSize(metrics.visibleSizeBytes), reserved: formatFileSize(metrics.reservedSizeBytes), })} -

+
-
-
+
+
{t('kb.metricPendingCleanup')} -
-
+ +
{format.number(metrics.pendingCleanupCount)} -
-

+ +

{t('kb.metricPendingCleanupSize', { size: formatFileSize(metrics.pendingCleanupSizeBytes), })} -

+
-
-
+
+
{t('kb.metricLinkedConsumers')} -
-
+ +
{format.number(metrics.linkedConsumerCount)} -
-

+ +

{t('kb.metricQuotaResources', { count: metrics.quotaResourceCount, })} -

+
-
+ {metrics.pendingCleanupCount > 0 ? (

{t('kb.quotaReleaseMessage')} @@ -182,6 +182,11 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { ) : null} ) : null} +

- -
+
) } diff --git a/packages/kb-management/src/KnowledgeBaseManager.tsx b/packages/kb-management/src/KnowledgeBaseManager.tsx index 9acd32fd86..e784a6c5bc 100644 --- a/packages/kb-management/src/KnowledgeBaseManager.tsx +++ b/packages/kb-management/src/KnowledgeBaseManager.tsx @@ -5,7 +5,7 @@ import { } from '@klicker-uzh/graphql/dist/ops' import { Button, - H2, + H1, H3, Skeleton, TextField, @@ -62,9 +62,9 @@ function KnowledgeBaseManager() { } return ( -
+
-

{t('kb.title')}

+

{t('kb.title')}

+
) } diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md index 4803e0e576..c41f14faac 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -266,13 +266,15 @@ Approval ratifies R0 and the named S1–S5 local work only; it does not authoriz | Slice | Owner | Dependency | Acceptance | | --- | --- | --- | --- | -| S1 Resource-first page shell | KB implementation executor | R0 contract | Existing four metrics are compacted; Resources precedes configuration; KB-local H1/main; existing creation forms remain reachable until S3. | +| S1 Resource-first page shell | Main session | R0 contract | Existing four metrics are compacted; Resources precedes configuration; KB-local H1/main; existing creation forms remain reachable until S3. | | S2 Metadata table | KB implementation executor | S1 | Semantic responsive table preserves server search, cursor load-more, polling, selection, inspector, and actions. | | S3 Unified add-resource chooser | KB implementation executor | S2 | One `+`; Website and Document use existing mutations; duplicate forms/links are removed only after the chooser works; Video is announced unavailable; creation remains explicit `ADDED` until ingestion. | | S4 Secondary configuration | Main session | S1–S3 | Chatbot/graph summaries reduce clutter without changing graph polling, pointers, publication rules, or ledger lifecycle. | | S5 Integrated proof and docs | Main session | S1–S4 | Focused Playwright checks, mandatory browser evidence, package checks, final review, wiki/skill updates, and a clean scoped diff. | | X1/X2 follow-ups | Separate authorized task | Independent contracts | Manage shell/shared modal or API/media-type changes are planned and verified separately. | +Execution-tier skip reason for S1: main retains this slice because it spans the catalog/detail semantic shell and the detail-section ordering that S2/S3 must integrate against; delegating it would add coupling at the critical path. + ### Target files and documentation The core implementation should stay in the reusable package: @@ -305,6 +307,10 @@ Pause before implementation or between slices for a material product/API decisio - [x] Planner review completed; scope, existing contracts, table primitive, sequencing, evidence traceability, and execution boundaries corrected. - [x] Independent Sol final review completed after corrections; roadmap approved with no remaining concerns. - [x] User approved the KB-local redesign package and implementation goal; S1 is next. +- [x] S1 resource-first shell implemented locally: KB catalog/detail use semantic `main`/`H1` landmarks, metrics are compact metadata, and Resources precedes creation/configuration. `@klicker-uzh/kb-management` check and targeted formatting pass. +- [ ] S2 metadata table. +- [ ] S3 unified add-resource chooser. +- [ ] S4 secondary configuration and integrated browser proof. ## Verification plan for implementation From 07452f7159bc939c9923f1e4be9b69cf9d9dd732 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 22:46:45 +0200 Subject: [PATCH 03/24] enhance(kb): present resources as metadata table --- packages/i18n/messages/de.ts | 4 + packages/i18n/messages/en.ts | 4 + .../kb-management/src/KnowledgeBaseDetail.tsx | 33 +- .../components/KnowledgeBaseResourceList.tsx | 343 +++++++++++------- ...8-24-kb-management-ux-audit-and-roadmap.md | 4 +- 5 files changed, 239 insertions(+), 149 deletions(-) diff --git a/packages/i18n/messages/de.ts b/packages/i18n/messages/de.ts index 862c560d7d..b447e95b88 100644 --- a/packages/i18n/messages/de.ts +++ b/packages/i18n/messages/de.ts @@ -1534,6 +1534,7 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die '{resources, plural, one {# Ressource} other {# Ressourcen}} · {chatbots, plural, one {# verknüpfter Chatbot} other {# verknüpfte Chatbots}}', loadMore: 'Weitere Wissensdatenbanken laden', notFound: 'Die Wissensdatenbank konnte nicht gefunden werden.', + detailFallbackTitle: 'Wissensdatenbank', backToList: 'Zurück zu den Wissensdatenbanken', metricsTitle: 'Nutzung und Verknüpfungen', metricVisibleResources: 'Sichtbare Ressourcen', @@ -1568,6 +1569,8 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die linkSuccess: 'Link wurde zur Wissensdatenbank hinzugefügt.', linkError: 'Der Link konnte nicht hinzugefügt werden.', resourcesTitle: 'Ressourcen', + resourceColumn: 'Ressource', + resourceActions: 'Aktionen', resourcesLoadError: 'Die Ressourcen konnten nicht geladen werden.', searchResources: 'Ressourcen suchen', searchResourcesPlaceholder: 'Titel, Dateiname oder URL suchen', @@ -1583,6 +1586,7 @@ Da die KlickerUZH-App noch nicht im iOS-App-Store verfügbar ist, folgen Sie die selectResource: '„{title}“ auswählen', loadMoreResources: 'Weitere Ressourcen laden', noResources: 'Es wurden noch keine Ressourcen hinzugefügt.', + updatedAtLabel: 'Aktualisiert', updatedAt: 'Aktualisiert {date}', statusAdded: 'Hinzugefügt', statusQueued: 'In Warteschlange', diff --git a/packages/i18n/messages/en.ts b/packages/i18n/messages/en.ts index 0013aa0891..2b172d8e68 100644 --- a/packages/i18n/messages/en.ts +++ b/packages/i18n/messages/en.ts @@ -1526,6 +1526,7 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these '{resources, plural, one {# resource} other {# resources}} · {chatbots, plural, one {# connected chatbot} other {# connected chatbots}}', loadMore: 'Load more knowledge bases', notFound: 'The knowledge base could not be found.', + detailFallbackTitle: 'Knowledge base', backToList: 'Back to knowledge bases', metricsTitle: 'Usage and connections', metricVisibleResources: 'Visible resources', @@ -1558,6 +1559,8 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these linkSuccess: 'Link added to the knowledge base.', linkError: 'The link could not be added.', resourcesTitle: 'Resources', + resourceColumn: 'Resource', + resourceActions: 'Actions', resourcesLoadError: 'The resources could not be loaded.', searchResources: 'Search resources', searchResourcesPlaceholder: 'Search title, filename or URL', @@ -1573,6 +1576,7 @@ Since the KlickerUZH app is not yet available in the iOS App Store, follow these selectResource: 'Select “{title}”', loadMoreResources: 'Load more resources', noResources: 'No resources have been added yet.', + updatedAtLabel: 'Updated', updatedAt: 'Updated {date}', statusAdded: 'Added', statusQueued: 'Queued', diff --git a/packages/kb-management/src/KnowledgeBaseDetail.tsx b/packages/kb-management/src/KnowledgeBaseDetail.tsx index 4b69f47b54..f89d8e3d08 100644 --- a/packages/kb-management/src/KnowledgeBaseDetail.tsx +++ b/packages/kb-management/src/KnowledgeBaseDetail.tsx @@ -24,27 +24,29 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) {
-
) } @@ -52,6 +54,7 @@ function KnowledgeBaseDetail({ kbId }: { kbId: string }) { if (error || !data?.getKb) { return (
+

{t('kb.detailFallbackTitle')}

-

{t('kb.metricsTitle')} -

+
diff --git a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx index 9a9e405e47..fbd12ad7fe 100644 --- a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx @@ -25,9 +25,16 @@ import { import { Badge, Button, - H3, + H2, Modal, SelectField, + ShadcnTable, + ShadcnTableBody, + ShadcnTableCaption, + ShadcnTableCell, + ShadcnTableHead, + ShadcnTableHeader, + ShadcnTableRow, Skeleton, TextField, UserNotification, @@ -881,7 +888,7 @@ function KnowledgeBaseResourceList({ return (
-

{t('kb.resourcesTitle')}

+

{t('kb.resourcesTitle')}

{selectedIds.size > 0 ? (
+ {t('kb.inspectResource')} + +
-
-
- - -
-
-
- {t('kb.updatedAt', { - date: format.dateTime(new Date(resource.updatedAt), { - dateStyle: 'medium', - timeStyle: 'short', - }), - })} -
- - ) - })} - + + + ) + })} + + {connection?.pageInfo.hasNextPage ? (
) } diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx new file mode 100644 index 0000000000..cdc3f00135 --- /dev/null +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -0,0 +1,137 @@ +import { Button, Modal } from '@uzh-bf/design-system' +import { useTranslations } from 'next-intl' +import React, { useEffect, useState } from 'react' +import KnowledgeBaseFileDropzone from './KnowledgeBaseFileDropzone' +import KnowledgeBaseUrlForm from './KnowledgeBaseUrlForm' + +type AddResourceMode = 'chooser' | 'website' | 'document' + +function KnowledgeBaseAddResourceModal({ + kbId, + onClose, + onResourceCreated, +}: { + kbId: string + onClose: () => void + onResourceCreated: () => Promise +}) { + const t = useTranslations() + const [mode, setMode] = useState('chooser') + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const selector = + mode === 'chooser' + ? '[data-cy="choose-kb-resource-website"]' + : mode === 'website' + ? '[data-cy="kb-url-title"]' + : '[data-cy="kb-file-dropzone"]' + document.querySelector(selector)?.focus() + }) + + return () => window.cancelAnimationFrame(frame) + }, [mode]) + + const handleResourceCreated = async () => { + await onResourceCreated() + onClose() + } + + const isChooser = mode === 'chooser' + + return ( + { + if (isChooser) onClose() + else setMode('chooser') + }} + dataContent={{ cy: 'kb-add-resource-modal' }} + dataCloseButton={{ cy: 'close-kb-add-resource-modal' }} + dataSecondaryAction={{ cy: 'back-kb-add-resource' }} + className={{ content: 'max-w-3xl' }} + > + {isChooser ? ( +
+

+ {t('kb.addResourceDescription')} +

+
+ + + +
+
+ ) : mode === 'website' ? ( + + ) : ( + + )} +
+ ) +} + +export default KnowledgeBaseAddResourceModal diff --git a/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx b/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx index 71ddc2a954..bd0db4d442 100644 --- a/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx @@ -24,9 +24,11 @@ const ACCEPTED_FILES = { function KnowledgeBaseFileDropzone({ kbId, + embedded = false, onResourceCreated, }: { kbId: string + embedded?: boolean onResourceCreated: () => Promise }) { const t = useTranslations() @@ -112,13 +114,9 @@ function KnowledgeBaseFileDropzone({ toast({ type: 'error', message: t('kb.fileRejected') }), }) - return ( -
-

{t('kb.fileUploadTitle')}

+ const content = ( + <> + {!embedded ?

{t('kb.fileUploadTitle')}

: null}

{t('kb.fileUploadDescription')}

@@ -143,6 +141,18 @@ function KnowledgeBaseFileDropzone({ {t('kb.fileUploadFormats')}
+ + ) + + return embedded ? ( +
{content}
+ ) : ( +
+ {content}
) } diff --git a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx index fbd12ad7fe..36140ab4e9 100644 --- a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx @@ -9,6 +9,7 @@ import { import { faFileLines, faLink, + faPlus, faSpinner, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -312,10 +313,12 @@ function KnowledgeBaseResourceList({ kbId, refreshKey, onMetricsChanged, + onAddResource, }: { kbId: string refreshKey: number onMetricsChanged: () => Promise + onAddResource: () => void }) { const t = useTranslations() const format = useFormatter() @@ -889,17 +892,27 @@ function KnowledgeBaseResourceList({

{t('kb.resourcesTitle')}

- {selectedIds.size > 0 ? ( +
- ) : null} + {selectedIds.size > 0 ? ( + + ) : null} +
@@ -1004,22 +1017,9 @@ function KnowledgeBaseResourceList({ : t('kb.noResources')}

{!deferredSearch && !typeFilter && !statusFilter ? ( -
- - {t('kb.fileUploadTitle')} - - - {t('kb.linkTitle')} - -
+

+ {t('kb.emptyResourceHint')} +

) : null}
) : ( diff --git a/packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx b/packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx index d5395614ba..0ee3e4d47c 100644 --- a/packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseUrlForm.tsx @@ -17,9 +17,11 @@ function isValidWebUrl(value: string) { function KnowledgeBaseUrlForm({ kbId, + embedded = false, onResourceCreated, }: { kbId: string + embedded?: boolean onResourceCreated: () => Promise }) { const t = useTranslations() @@ -66,13 +68,9 @@ function KnowledgeBaseUrlForm({ toast({ type: 'success', message: t('kb.linkSuccess') }) } - return ( - + + {binding.chatbotName} + + + + ))} + + )} + + + )} +
+ ) } diff --git a/packages/kb-management/src/components/KnowledgeGraphPanel.tsx b/packages/kb-management/src/components/KnowledgeGraphPanel.tsx index 69441a167a..4a6362a8fb 100644 --- a/packages/kb-management/src/components/KnowledgeGraphPanel.tsx +++ b/packages/kb-management/src/components/KnowledgeGraphPanel.tsx @@ -24,7 +24,7 @@ import { import type { KnowledgeGraphDataSource } from '@klicker-uzh/shared-components/src/knowledgeGraph/knowledgeGraphState' import { KnowledgeGraphUnavailableError } from '@klicker-uzh/shared-components/src/knowledgeGraph/knowledgeGraphState' import type { KnowledgeGraphResponse } from '@klicker-uzh/types' -import { Badge, Button, H3, SelectField, Switch } from '@uzh-bf/design-system' +import { Badge, Button, SelectField, Switch } from '@uzh-bf/design-system' import { useFormatter, useTranslations } from 'next-intl' import dynamic from 'next/dynamic' import React, { useEffect, useMemo, useState } from 'react' @@ -206,19 +206,14 @@ function KnowledgeGraphPreview({ kbId }: { kbId: string }) { ) } -function KnowledgeGraphPanel({ - kbId, - compact = false, -}: { - kbId: string - compact?: boolean -}) { +function KnowledgeGraphPanel({ kbId }: { kbId: string }) { const t = useTranslations() const format = useFormatter() const [selectedTier, setSelectedTier] = useState( KbGraphQualityTier.Standard ) const [operationError, setOperationError] = useState(null) + const [detailsOpen, setDetailsOpen] = useState(false) const { data, loading, error, refetch, startPolling, stopPolling } = useQuery( GetKbKnowledgeGraphConfigDocument, { @@ -292,6 +287,20 @@ function KnowledgeGraphPanel({ released: t('kb.graphCostStatusReleased'), needsHumanReview: t('kb.graphCostStatusNeedsHumanReview'), } + const graphSummary = + loading && data === undefined + ? t('kb.graphLoading') + : error || config === undefined + ? t('kb.graphLoadError') + : [ + `${t('kb.graphStatusLabel')}: ${statusLabel(config.status, statusLabels)}`, + config.isStale && hasPublishedGraph ? t('kb.graphStale') : null, + config.costStatus === KbGraphCostStatus.NeedsHumanReview + ? costStatusLabel(config.costStatus, costStatusLabels) + : null, + ] + .filter((value): value is string => Boolean(value)) + .join(' · ') const handleRebuild = async () => { if (isRebuilding || isActive || !config?.isEnabled) return @@ -324,230 +333,241 @@ function KnowledgeGraphPanel({ } return ( -
setDetailsOpen(event.currentTarget.open)} > -
- {!compact ?

{t('kb.graphTitle')}

: null} -

+ - {t('kb.graphDescription')} -

-
- - {loading && data === undefined ? ( -

- {t('kb.graphLoading')} -

- ) : error || config === undefined ? ( -
+ -
- {t('kb.graphLoadError')} - -
-
- ) : ( - <> -
- void handleEnabledChange(enabled)} - disabled={isTogglingEnabled} - data={{ cy: 'kb-knowledge-graph-enabled' }} - /> -

- {config.isEnabled - ? t('kb.graphEnabledDescription') - : t('kb.graphDisabledDescription')} -

- {!config.costConfigurationReady ? ( -

- {t('kb.graphCostUnavailable')} -

- ) : null} -
- - setSelectedTier(value as KbGraphQualityTier) - } - disabled={ - isActive || - isRebuilding || - !config.isEnabled || - !config.costConfigurationReady - } - data={{ cy: 'kb-knowledge-graph-quality-tier' }} - /> + {graphSummary} + + + {t('kb.configure')} + + +
+

{t('kb.graphDescription')}

+ + {loading && data === undefined ? ( +

+ {t('kb.graphLoading')} +

+ ) : error || config === undefined ? ( +
+
+ {t('kb.graphLoadError')}
-

- {t('kb.graphBuildCost', { amount: formattedSelectedEstimate })} -

-
-

- - {t('kb.graphBillingLabel')}: - {' '} - {formattedBillingLabel} -

-

- - {t('kb.graphRemainingQuota')}: - {' '} - {formatMinorUnits( - format, - config.remainingSemesterQuotaMinorUnits, - config.quotaCurrency - )} -

-

- - {t('kb.graphWorstCaseBalance')}: - {' '} - {formatMinorUnits( - format, - config.worstCaseRemainingMinorUnits, - config.quotaCurrency - )} +

+ ) : ( + <> +
+ void handleEnabledChange(enabled)} + disabled={isTogglingEnabled} + data={{ cy: 'kb-knowledge-graph-enabled' }} + /> +

+ {config.isEnabled + ? t('kb.graphEnabledDescription') + : t('kb.graphDisabledDescription')}

-

- {t('kb.graphMaxCost')}:{' '} - {formatMinorUnits( - format, - config.maxCostMinorUnits, - config.quotaCurrency - )} + {!config.costConfigurationReady ? ( +

+ {t('kb.graphCostUnavailable')} +

+ ) : null} +
+ + setSelectedTier(value as KbGraphQualityTier) + } + disabled={ + isActive || + isRebuilding || + !config.isEnabled || + !config.costConfigurationReady + } + data={{ cy: 'kb-knowledge-graph-quality-tier' }} + /> + +
+

+ {t('kb.graphBuildCost', { amount: formattedSelectedEstimate })}

- {config.costStatus ? ( +

- {t('kb.graphCostStatus')}: + {t('kb.graphBillingLabel')}: {' '} - {costStatusLabel(config.costStatus, costStatusLabels)} + {formattedBillingLabel}

- ) : null} - {config.actualCostMinorUnits != null ? ( -

+

- {t('kb.graphActualCost')}: + {t('kb.graphRemainingQuota')}: {' '} {formatMinorUnits( format, - config.actualCostMinorUnits, - config.costCurrency + config.remainingSemesterQuotaMinorUnits, + config.quotaCurrency )}

- ) : null} -
- {config.actualRequestCount != null ? ( -

- {t('kb.graphActualUsage', { - requests: config.actualRequestCount, - inputTokens: config.actualInputTokens ?? 0, - outputTokens: config.actualOutputTokens ?? 0, - embeddingTokens: config.actualEmbeddingTokens ?? 0, - })} -

- ) : null} -
-
- - {t('kb.graphStatusLabel')}: - - - {statusLabel(config.status, statusLabels)} - - {config.isStale && hasPublishedGraph ? ( - {t('kb.graphStale')} +

+ + {t('kb.graphWorstCaseBalance')}: + {' '} + {formatMinorUnits( + format, + config.worstCaseRemainingMinorUnits, + config.quotaCurrency + )} +

+

+ {t('kb.graphMaxCost')}:{' '} + {formatMinorUnits( + format, + config.maxCostMinorUnits, + config.quotaCurrency + )} +

+ {config.costStatus ? ( +

+ + {t('kb.graphCostStatus')}: + {' '} + {costStatusLabel(config.costStatus, costStatusLabels)} +

+ ) : null} + {config.actualCostMinorUnits != null ? ( +

+ + {t('kb.graphActualCost')}: + {' '} + {formatMinorUnits( + format, + config.actualCostMinorUnits, + config.costCurrency + )} +

) : null}
- {config.buildId ? ( -

- {t('kb.graphBuildId', { buildId: config.buildId })} + {config.actualRequestCount != null ? ( +

+ {t('kb.graphActualUsage', { + requests: config.actualRequestCount, + inputTokens: config.actualInputTokens ?? 0, + outputTokens: config.actualOutputTokens ?? 0, + embeddingTokens: config.actualEmbeddingTokens ?? 0, + })}

) : null} +
+
+ + {t('kb.graphStatusLabel')}: + + + {statusLabel(config.status, statusLabels)} + + {config.isStale && hasPublishedGraph ? ( + {t('kb.graphStale')} + ) : null} +
+ {config.buildId ? ( +

+ {t('kb.graphBuildId', { buildId: config.buildId })} +

+ ) : null} +
-
- - {operationError ? ( -

- {operationError} -

- ) : null} -
-

- {t('kb.graphPreviewTitle')} -

- {hasPublishedGraph ? ( - - ) : ( -
- {t('kb.graphPreviewUnavailable')} + {operationError} +

+ ) : null} + + {detailsOpen ? ( +
+

+ {t('kb.graphPreviewTitle')} +

+ {hasPublishedGraph ? ( + + ) : ( +
+ {t('kb.graphPreviewUnavailable')} +
+ )}
- )} -
- - )} -
+ ) : null} + + )} +
+ ) } diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts new file mode 100644 index 0000000000..0f7472396b --- /dev/null +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -0,0 +1,157 @@ +import { URL_MANAGE } from '../util/constants.js' +import { expect, test } from '../util/fixtures.js' + +test.describe('Knowledge base management workspace', () => { + test('keeps the resource workspace scannable and add flow keyboard-accessible in English and German', async ({ + loginLecturer, + page, + }, testInfo) => { + await loginLecturer() + + const manageUrl = process.env.URL_MANAGE ?? URL_MANAGE + const kbName = `UX review ${Date.now()}` + const resourceTitle = `UX website ${Date.now()}` + let detailPath: string | undefined + + try { + await page.goto(`${manageUrl}/resources/knowledgeBases`) + await expect( + page.getByRole('main').getByRole('heading', { level: 1 }) + ).toBeVisible() + + await page.getByTestId('create-knowledge-base').click() + await page.getByTestId('knowledge-base-name').fill(kbName) + await page.getByTestId('submit-create-knowledge-base').click() + + const knowledgeBaseLink = page + .getByRole('link') + .filter({ hasText: kbName }) + await expect(knowledgeBaseLink).toBeVisible() + detailPath = new URL( + (await knowledgeBaseLink.getAttribute('href')) ?? '', + manageUrl + ).pathname + await knowledgeBaseLink.click() + + const detail = page.getByTestId('knowledge-base-detail') + await expect(detail.getByRole('heading', { level: 1 })).toContainText( + kbName + ) + await expect(page.getByTestId('kb-metrics')).toBeVisible() + + const chatbotSettings = page.getByTestId('kb-chatbot-settings') + const graphSettings = page.getByTestId('kb-graph-settings') + await expect(chatbotSettings).not.toHaveAttribute('open') + await expect(graphSettings).not.toHaveAttribute('open') + await expect( + chatbotSettings.getByText(/Configure|Konfigurieren/) + ).toBeVisible() + await expect( + graphSettings.getByText(/Configure|Konfigurieren/) + ).toBeVisible() + + await page.getByTestId('add-kb-resource').focus() + await page.getByTestId('add-kb-resource').click() + const modal = page.getByTestId('kb-add-resource-modal') + await expect(modal).toHaveRole('dialog') + await expect(modal).toHaveAttribute( + 'aria-describedby', + 'kb-add-resource-description' + ) + await expect(modal.locator('#kb-add-resource-description')).toBeVisible() + await expect(page.getByTestId('choose-kb-resource-video')).toBeDisabled() + await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() + + const modalButtons = modal.getByRole('button') + const lastModalButton = modalButtons.last() + await page.getByTestId('choose-kb-resource-website').press('Shift+Tab') + await expect(lastModalButton).toBeFocused() + await lastModalButton.press('Tab') + await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() + + await page.getByTestId('choose-kb-resource-website').click() + await expect(page.getByTestId('kb-url-title')).toBeFocused() + await page.getByTestId('back-kb-add-resource').click() + await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() + await page.getByTestId('back-kb-add-resource').click() + await expect(modal).toBeHidden() + await expect(page.getByTestId('add-kb-resource')).toBeFocused() + + await page.getByTestId('add-kb-resource').click() + await page.getByTestId('choose-kb-resource-website').click() + await page.getByTestId('kb-url-title').fill(resourceTitle) + await page + .getByTestId('kb-url') + .fill(`https://example.org/${resourceTitle.replaceAll(' ', '-')}`) + await page.getByTestId('add-kb-url-resource').click() + await expect(modal).toBeHidden() + + const resourceTable = page.getByRole('table') + await expect(resourceTable).toBeVisible() + await expect( + resourceTable.getByRole('columnheader', { name: /Resource|Ressource/ }) + ).toBeVisible() + await expect( + resourceTable.getByRole('columnheader', { + name: /Latest ingestion|Letzte Verarbeitung/, + }) + ).toBeVisible() + const resourceRow = resourceTable.getByRole('row').filter({ + hasText: resourceTitle, + }) + await expect(resourceRow).toBeVisible() + + await page.setViewportSize({ width: 390, height: 844 }) + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ) + .toBe(true) + await page.screenshot({ + path: testInfo.outputPath('kb-management-en-mobile.png'), + fullPage: true, + }) + + await page.goto(`${manageUrl}/de${detailPath}`) + await expect(page.getByTestId('knowledge-base-detail')).toBeVisible() + await expect(page.getByTestId('add-kb-resource')).toContainText( + 'Ressource hinzufügen' + ) + await expect( + page.getByTestId('kb-chatbot-settings').getByText('Konfigurieren') + ).toBeVisible() + await page.screenshot({ + path: testInfo.outputPath('kb-management-de-mobile.png'), + fullPage: true, + }) + } finally { + if (detailPath) { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(`${manageUrl}${detailPath}`) + const resourceRow = page + .getByRole('table') + .getByRole('row') + .filter({ hasText: resourceTitle }) + if (await resourceRow.count()) { + await resourceRow + .getByRole('button', { name: /Delete|Löschen/ }) + .click() + await page.getByTestId('confirm-delete-kb-resource').click() + await expect(resourceRow).toHaveCount(0) + } + + await page.goto(`${manageUrl}/resources/knowledgeBases`) + const knowledgeBaseRow = page.locator('li').filter({ hasText: kbName }) + if (await knowledgeBaseRow.count()) { + await knowledgeBaseRow + .getByRole('button', { name: /Delete|Löschen/ }) + .click() + await page.getByTestId('confirm-delete-knowledge-base').click() + await expect(knowledgeBaseRow).toHaveCount(0) + } + } + } + }) +}) diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md index be18fce5cf..5b8f44a600 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -311,7 +311,8 @@ Pause before implementation or between slices for a material product/API decisio - [x] S2 metadata table implemented locally: the resource cards are now a semantic design-system table with responsive metadata, server-backed filters/search/load-more/polling, selection, inspector, deletion, and ingestion behavior preserved. `@klicker-uzh/kb-management` check and targeted formatting pass. - [x] S3 unified add-resource chooser implemented locally: one `+` action opens a KB-local chooser for Website and Document, Video is disabled as coming soon, existing BLOB/URL mutations remain in explicit forms, and the old empty-state links/direct form stack are removed. `@klicker-uzh/kb-management` check and targeted formatting pass. - [x] S3 accessibility correction completed after review: the chooser now associates its description, traps Tab locally, and restores focus to the exact `+ Add resource` trigger after close. No shared modal change was required. -- [ ] S4 secondary configuration and integrated browser proof. +- [x] S4 secondary configuration implemented locally: chatbot and graph configuration own collapsed disclosures with explicit Configure affordances; graph status, stale, and human-review states remain in the summary, and the expensive graph preview mounts only after expansion. Existing queries, polling, mutations, pointers, and lifecycle contracts are unchanged. The focused Playwright spec covers the English/German workspace, chooser keyboard path, table metadata, and mobile overflow. +- [ ] S5 integrated browser proof, documentation review, and final package review. ## Verification plan for implementation From dfa33dd5221e16ec34667154b5a988cfd3093547 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 23:31:10 +0200 Subject: [PATCH 07/24] fix(kb): protect resource creation lifecycle --- .agents/skills/klicker-frontend-ui/SKILL.md | 2 +- docs/frontend-conventions.md | 2 + .../KnowledgeBaseAddResourceModal.tsx | 22 +++++---- .../components/KnowledgeBaseFileDropzone.tsx | 8 +++- .../components/KnowledgeBaseResourceList.tsx | 48 ++++++++++++++----- playwright/tests/Y-kb-management-ux.spec.ts | 16 +++++-- 6 files changed, 70 insertions(+), 28 deletions(-) diff --git a/.agents/skills/klicker-frontend-ui/SKILL.md b/.agents/skills/klicker-frontend-ui/SKILL.md index 0b1e6af0f3..b2202e1dfd 100644 --- a/.agents/skills/klicker-frontend-ui/SKILL.md +++ b/.agents/skills/klicker-frontend-ui/SKILL.md @@ -43,7 +43,7 @@ Conventions (design system, Tailwind v4, Apollo, i18n, CSP): [docs/frontend-conv ## App boundaries - `frontend-manage` (lecturer), `frontend-pwa` (student; also has a localforage offline side-channel for live-quiz answers — don't bypass `storageHelpers.ts`), `frontend-control` (mobile controller), `auth` (login flows — auth changes also need [docs/auth-model.md](../../../docs/auth-model.md)). -- Knowledge-base management is a reusable package mounted by `frontend-manage`: edit `packages/kb-management`, not duplicate app-local components. Verify `/resources/knowledgeBases` plus the detail route at desktop and mobile widths, both locales, and every changed empty/active/success/failure state. +- Knowledge-base management is a reusable package mounted by `frontend-manage`: edit `packages/kb-management`, not duplicate app-local components. The detail resource workspace uses a semantic metadata table, one `+ Add resource` chooser for Website/Document with Video disabled until supported, and an overflow menu for destructive row actions. Preserve the explicit `ADDED`-before-Ingest lifecycle and keep the chooser non-dismissible after a file upload ticket is requested until confirmation or terminal failure. Verify `/resources/knowledgeBases` plus the detail route at desktop and mobile widths, both locales, and every changed empty/active/success/failure state. - The KB navigation item is an interim `user.privatePreview` discovery gate. Direct catalog/detail URLs must render the localized `KB_PREVIEW_ACCESS_REQUIRED` service error for a non-preview lecturer; never rely on hidden navigation as authorization. - The knowledge-resource Ingest action accepts only the resource identifier. Do not expose transport tuning in the UI unless the GraphQL and ingestion-platform contracts add a real user-controlled setting. - Keep full KB attempt history out of the two-second detail poll. Load the bounded, owner-checked history query only when a lecturer expands a resource, while the parent query carries only the latest run needed for operation status. diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md index 0a424d7725..9cd7a3b4ae 100644 --- a/docs/frontend-conventions.md +++ b/docs/frontend-conventions.md @@ -84,6 +84,8 @@ The lecturer routes `apps/frontend-manage/src/pages/resources/knowledgeBases.tsx The catalog uses server search and cursor-driven “load more” rather than loading all owned KBs. The detail page keeps metadata/metrics separate from `packages/kb-management/src/components/KnowledgeBaseResourceList.tsx:KnowledgeBaseResourceList`, which owns server search plus design-system type/status filters, selection, confirmed bulk deletion, the source inspector, and contextual Ingest/Retry/Re-ingest/Delete actions. While any loaded row is `QUEUED`/`PROCESSING`, the two-second interval fetches page zero plus pages known to contain active rows and runs a full loaded-window walk every tenth tick. Cursor or page-length drift triggers an immediate full walk. Promise-only polls use `ApolloClient.query` with `no-cache`; generation fencing, the latest loaded-count ref, and shared cache merge preserve the loaded window and remove rows from selection when they become active. Show indeterminate real-operation progress and safe-to-leave messaging rather than invented percentages. +The detail resource workspace presents that loaded window as a semantic design-system table with source, operation, serving, update, selection, and contextual action metadata. Use one `+ Add resource` action that opens the chooser for Website or Document; keep Video visibly unavailable until its ingestion contract exists. Keep destructive resource deletion in the row overflow menu, and preserve the explicit inspector Ingest action for resources that are only `ADDED`. When a file upload has requested its ticket, keep the chooser open and non-dismissible until upload confirmation or a terminal failure. + The inspector loads the owner-checked five-attempt history lazily. Full attempt history must stay outside the two-second list poll. Lecturer-facing failure detail is localized from stable status/error codes; raw platform messages are not rendered. Transport tuning is not user-controlled. Changes must preserve EN/DE messages, `data-cy` hooks, keyboard/focus behavior, and browser evidence for desktop plus 390 px mobile states, including search/filter, selection/confirmation, empty, active, ready, failed, and replacement-cutover feedback where affected. KB and resource deletion dialogs explain the two observable phases: the item disappears immediately, while stored files and the external index are removed in the background. Success toasts confirm removal from the lecturer view without claiming that external cleanup has already completed. diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx index 8aae8ff585..c3de28fbcb 100644 --- a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -19,12 +19,18 @@ function KnowledgeBaseAddResourceModal({ }) { const t = useTranslations() const [mode, setMode] = useState('chooser') + const [uploadingDocument, setUploadingDocument] = useState(false) - const handleClose = () => { + const closeModal = () => { onClose() window.requestAnimationFrame(() => triggerRef.current?.focus()) } + const handleClose = () => { + if (uploadingDocument) return + closeModal() + } + useEffect(() => { const modal = document.querySelector( '[data-cy="kb-add-resource-modal"]' @@ -81,7 +87,7 @@ function KnowledgeBaseAddResourceModal({ const handleResourceCreated = async () => { await onResourceCreated() - handleClose() + closeModal() } const isChooser = mode === 'chooser' @@ -90,6 +96,8 @@ function KnowledgeBaseAddResourceModal({ { - if (isChooser) handleClose() - else setMode('chooser') - }} + secondaryLabel={isChooser ? undefined : t('kb.backToResourceTypes')} + onSecondaryAction={isChooser ? undefined : () => setMode('chooser')} dataContent={{ cy: 'kb-add-resource-modal' }} dataCloseButton={{ cy: 'close-kb-add-resource-modal' }} dataSecondaryAction={{ cy: 'back-kb-add-resource' }} @@ -171,6 +174,7 @@ function KnowledgeBaseAddResourceModal({ )} diff --git a/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx b/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx index bd0db4d442..bf5b84fc2e 100644 --- a/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseFileDropzone.tsx @@ -5,7 +5,7 @@ import { } from '@klicker-uzh/graphql/dist/ops' import { H3, toast } from '@uzh-bf/design-system' import { useTranslations } from 'next-intl' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useDropzone } from 'react-dropzone' import { getGraphQLErrorCode } from '../graphqlError' import { refreshAfterMutation } from '../refreshAfterMutation' @@ -25,10 +25,12 @@ const ACCEPTED_FILES = { function KnowledgeBaseFileDropzone({ kbId, embedded = false, + onUploadStateChange, onResourceCreated, }: { kbId: string embedded?: boolean + onUploadStateChange?: (uploading: boolean) => void onResourceCreated: () => Promise }) { const t = useTranslations() @@ -36,6 +38,10 @@ function KnowledgeBaseFileDropzone({ const [requestUpload] = useMutation(RequestKbFileUploadDocument) const [confirmUpload] = useMutation(ConfirmKbFileUploadDocument) + useEffect(() => { + onUploadStateChange?.(uploading) + }, [onUploadStateChange, uploading]) + const uploadFile = async (files: File[]) => { const file = files[0] if (!file || uploading) return diff --git a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx index ce97d607a6..3da1d91350 100644 --- a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx @@ -8,6 +8,7 @@ import { } from '@apollo/client' import { faFileLines, + faEllipsisVertical, faLink, faPlus, faSpinner, @@ -26,6 +27,7 @@ import { import { Badge, Button, + Dropdown, H2, Modal, SelectField, @@ -1229,29 +1231,49 @@ function KnowledgeBaseResourceList({ -
+
- + />
diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts index 0f7472396b..2b20a25984 100644 --- a/playwright/tests/Y-kb-management-ux.spec.ts +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -73,7 +73,7 @@ test.describe('Knowledge base management workspace', () => { await expect(page.getByTestId('kb-url-title')).toBeFocused() await page.getByTestId('back-kb-add-resource').click() await expect(page.getByTestId('choose-kb-resource-website')).toBeFocused() - await page.getByTestId('back-kb-add-resource').click() + await page.getByTestId('close-kb-add-resource-modal').click() await expect(modal).toBeHidden() await expect(page.getByTestId('add-kb-resource')).toBeFocused() @@ -100,6 +100,15 @@ test.describe('Knowledge base management workspace', () => { hasText: resourceTitle, }) await expect(resourceRow).toBeVisible() + await expect( + resourceRow.locator('[data-cy^="kb-resource-status-"]') + ).toContainText(/Added|Hinzugefügt/) + await resourceRow.getByTestId(/inspect-kb-resource-/).click() + await expect(page.getByTestId('kb-resource-inspector')).toBeVisible() + await expect( + page.getByTestId('ingest-kb-resource-inspector') + ).toContainText(/Ingest|Verarbeiten/) + await page.getByTestId('done-kb-resource-inspector').click() await page.setViewportSize({ width: 390, height: 844 }) await expect @@ -135,9 +144,8 @@ test.describe('Knowledge base management workspace', () => { .getByRole('row') .filter({ hasText: resourceTitle }) if (await resourceRow.count()) { - await resourceRow - .getByRole('button', { name: /Delete|Löschen/ }) - .click() + await resourceRow.getByTestId(/kb-resource-actions-/).click() + await page.getByTestId(/delete-kb-resource-/).click() await page.getByTestId('confirm-delete-kb-resource').click() await expect(resourceRow).toHaveCount(0) } From b900805aba77da21e48d1cea23f21b7a84a9d302 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 23:31:55 +0200 Subject: [PATCH 08/24] docs(project): update KB UX verification progress --- project/2026-08-24-kb-management-ux-audit-and-roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md index 5b8f44a600..9e4a42bac6 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -312,7 +312,8 @@ Pause before implementation or between slices for a material product/API decisio - [x] S3 unified add-resource chooser implemented locally: one `+` action opens a KB-local chooser for Website and Document, Video is disabled as coming soon, existing BLOB/URL mutations remain in explicit forms, and the old empty-state links/direct form stack are removed. `@klicker-uzh/kb-management` check and targeted formatting pass. - [x] S3 accessibility correction completed after review: the chooser now associates its description, traps Tab locally, and restores focus to the exact `+ Add resource` trigger after close. No shared modal change was required. - [x] S4 secondary configuration implemented locally: chatbot and graph configuration own collapsed disclosures with explicit Configure affordances; graph status, stale, and human-review states remain in the summary, and the expensive graph preview mounts only after expansion. Existing queries, polling, mutations, pointers, and lifecycle contracts are unchanged. The focused Playwright spec covers the English/German workspace, chooser keyboard path, table metadata, and mobile overflow. -- [ ] S5 integrated browser proof, documentation review, and final package review. +- [x] Final static review corrections applied locally: file-upload dismissal is locked after upload starts, chooser dismissal is singular, destructive deletion is in the row overflow menu, the `ADDED`-before-Ingest lifecycle is asserted in the focused spec, and the frontend KB conventions are documented. +- [ ] S5 integrated browser proof and final package review. ## Verification plan for implementation From 2b3d8cbb199b089d0aad88c96eb4dc3f322fc4a2 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 23:35:45 +0200 Subject: [PATCH 09/24] fix(kb): align UX package static checks --- .../src/components/KnowledgeBaseAddResourceModal.tsx | 2 +- .../src/components/KnowledgeBaseResourceList.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx index c3de28fbcb..7afb0767a1 100644 --- a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -69,7 +69,7 @@ function KnowledgeBaseAddResourceModal({ document.addEventListener('keydown', handleKeyDown, true) return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [mode]) + }, []) useEffect(() => { const frame = window.requestAnimationFrame(() => { diff --git a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx index 3da1d91350..4e0491639d 100644 --- a/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseResourceList.tsx @@ -1,14 +1,14 @@ import { + type ApolloQueryResult, NetworkStatus, useApolloClient, useLazyQuery, useMutation, useQuery, - type ApolloQueryResult, } from '@apollo/client' import { - faFileLines, faEllipsisVertical, + faFileLines, faLink, faPlus, faSpinner, @@ -17,12 +17,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { GetKbResourceIngestionRunsDocument, GetKbResourcesDocument, + type GetKbResourcesQuery, + type GetKbResourcesQueryVariables, IngestKbResourceDocument, KbIngestionStatus, KbResourceStatus, KbResourceType, - type GetKbResourcesQuery, - type GetKbResourcesQueryVariables, } from '@klicker-uzh/graphql/dist/ops' import { Badge, @@ -40,8 +40,8 @@ import { ShadcnTableRow, Skeleton, TextField, - UserNotification, toast, + UserNotification, } from '@uzh-bf/design-system' import { useFormatter, useTranslations } from 'next-intl' import React, { From ac821a5cbf56bfbdbb0bce58ca3a9542db76084e Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 23:40:38 +0200 Subject: [PATCH 10/24] fix(kb): lock resource mode during uploads --- .../src/components/KnowledgeBaseAddResourceModal.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx index 7afb0767a1..161b08e9f9 100644 --- a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -105,8 +105,12 @@ function KnowledgeBaseAddResourceModal({ ? t('kb.addWebsite') : t('kb.addDocument') } - secondaryLabel={isChooser ? undefined : t('kb.backToResourceTypes')} - onSecondaryAction={isChooser ? undefined : () => setMode('chooser')} + secondaryLabel={ + isChooser || uploadingDocument ? undefined : t('kb.backToResourceTypes') + } + onSecondaryAction={ + isChooser || uploadingDocument ? undefined : () => setMode('chooser') + } dataContent={{ cy: 'kb-add-resource-modal' }} dataCloseButton={{ cy: 'close-kb-add-resource-modal' }} dataSecondaryAction={{ cy: 'back-kb-add-resource' }} From f817a281dd25ceab33d6d13850b8e6d186911457 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 23:47:47 +0200 Subject: [PATCH 11/24] fix(kb): preserve creation close and narrow layout proof --- .../KnowledgeBaseAddResourceModal.tsx | 7 +++-- playwright/tests/Y-kb-management-ux.spec.ts | 26 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx index 161b08e9f9..b056a83230 100644 --- a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -86,8 +86,11 @@ function KnowledgeBaseAddResourceModal({ }, [mode]) const handleResourceCreated = async () => { - await onResourceCreated() - closeModal() + try { + await onResourceCreated() + } finally { + closeModal() + } } const isChooser = mode === 'chooser' diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts index 2b20a25984..2d0e6c8765 100644 --- a/playwright/tests/Y-kb-management-ux.spec.ts +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -110,19 +110,27 @@ test.describe('Knowledge base management workspace', () => { ).toContainText(/Ingest|Verarbeiten/) await page.getByTestId('done-kb-resource-inspector').click() - await page.setViewportSize({ width: 390, height: 844 }) - await expect - .poll(() => - page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth - ) - ) - .toBe(true) + await page.setViewportSize({ width: 1440, height: 900 }) await page.screenshot({ - path: testInfo.outputPath('kb-management-en-mobile.png'), + path: testInfo.outputPath('kb-management-en-desktop.png'), fullPage: true, }) + for (const width of [390, 320]) { + await page.setViewportSize({ width, height: 844 }) + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + ) + ) + .toBe(true) + await page.screenshot({ + path: testInfo.outputPath(`kb-management-en-${width}.png`), + fullPage: true, + }) + } + await page.goto(`${manageUrl}/de${detailPath}`) await expect(page.getByTestId('knowledge-base-detail')).toBeVisible() await expect(page.getByTestId('add-kb-resource')).toContainText( From 248d58c2207edb4b6f8d56c65c36bf3d2c08f260 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 24 Aug 2026 23:53:09 +0200 Subject: [PATCH 12/24] docs(project): record KB UX final review --- project/2026-08-24-kb-management-ux-audit-and-roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md index 9e4a42bac6..e029e50843 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -313,7 +313,8 @@ Pause before implementation or between slices for a material product/API decisio - [x] S3 accessibility correction completed after review: the chooser now associates its description, traps Tab locally, and restores focus to the exact `+ Add resource` trigger after close. No shared modal change was required. - [x] S4 secondary configuration implemented locally: chatbot and graph configuration own collapsed disclosures with explicit Configure affordances; graph status, stale, and human-review states remain in the summary, and the expensive graph preview mounts only after expansion. Existing queries, polling, mutations, pointers, and lifecycle contracts are unchanged. The focused Playwright spec covers the English/German workspace, chooser keyboard path, table metadata, and mobile overflow. - [x] Final static review corrections applied locally: file-upload dismissal is locked after upload starts, chooser dismissal is singular, destructive deletion is in the row overflow menu, the `ADDED`-before-Ingest lifecycle is asserted in the focused spec, and the frontend KB conventions are documented. -- [ ] S5 integrated browser proof and final package review. +- [x] Static S5 package proof complete: Sol final review passed the exact integrated range through `f817a281dd25ceab33d6d13850b8e6d186911457`, and the focused spec covers desktop plus 390px and 320px reflow guards. +- [ ] S5 browser proof remains pending because the exact implementation runtime cannot start under the existing lifecycle lock. ## Verification plan for implementation From 574ca2115d5c16abe5ad8e1d3f8564feb27d0aa2 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 01:11:33 +0200 Subject: [PATCH 13/24] docs(project): record full KB verification --- project/2026-08-24-kb-management-ux-audit-and-roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md index e029e50843..e808403d90 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -313,7 +313,8 @@ Pause before implementation or between slices for a material product/API decisio - [x] S3 accessibility correction completed after review: the chooser now associates its description, traps Tab locally, and restores focus to the exact `+ Add resource` trigger after close. No shared modal change was required. - [x] S4 secondary configuration implemented locally: chatbot and graph configuration own collapsed disclosures with explicit Configure affordances; graph status, stale, and human-review states remain in the summary, and the expensive graph preview mounts only after expansion. Existing queries, polling, mutations, pointers, and lifecycle contracts are unchanged. The focused Playwright spec covers the English/German workspace, chooser keyboard path, table metadata, and mobile overflow. - [x] Final static review corrections applied locally: file-upload dismissal is locked after upload starts, chooser dismissal is singular, destructive deletion is in the row overflow menu, the `ADDED`-before-Ingest lifecycle is asserted in the focused spec, and the frontend KB conventions are documented. -- [x] Static S5 package proof complete: Sol final review passed the exact integrated range through `f817a281dd25ceab33d6d13850b8e6d186911457`, and the focused spec covers desktop plus 390px and 320px reflow guards. +- [x] Static S5 package proof complete: Sol final review passed the exact implementation range through `f817a281dd25ceab33d6d13850b8e6d186911457`, the focused spec covers desktop plus 390px and 320px reflow guards, and the full repository typecheck passes 29/29 Turbo tasks after workspace outputs were generated. +- [x] Root formatting passes. Root lint remains environment-blocked in the unrelated analytics package: Turbo cannot initialize the host uv cache, a direct temporary-cache retry cannot build pandas because the host lacks `llvm-ar`, and the standalone ruff run exposes 97 pre-existing analytics findings outside this change. - [ ] S5 browser proof remains pending because the exact implementation runtime cannot start under the existing lifecycle lock. ## Verification plan for implementation From c58032b05cdafcc0a7e67513d01161206ccb2c5c Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 10:54:45 +0200 Subject: [PATCH 14/24] enhance(playwright): run local E2E from host --- .../skills/klicker-playwright-e2e/SKILL.md | 37 +++ AGENTS.md | 2 +- docs/getting-started.md | 1 + playwright/package.json | 1 + ...8-24-kb-management-ux-audit-and-roadmap.md | 5 +- util/run-host-e2e.sh | 297 ++++++++++++++++++ 6 files changed, 340 insertions(+), 3 deletions(-) create mode 100755 util/run-host-e2e.sh diff --git a/.agents/skills/klicker-playwright-e2e/SKILL.md b/.agents/skills/klicker-playwright-e2e/SKILL.md index 01af100b49..e68bc6a23a 100644 --- a/.agents/skills/klicker-playwright-e2e/SKILL.md +++ b/.agents/skills/klicker-playwright-e2e/SKILL.md @@ -35,6 +35,43 @@ rg -n "test\\(" playwright/tests ## Local Test Setup +### Host-run against a running devrouter workspace (preferred) + +Playwright is a black-box driver: run it from the host against the devrouter +routes. Browser binaries and node_modules come from shared host caches, so +never download browsers into a DevPod. + +```bash +# auto-detects routed worktrees / plain devcontainer / host-run apps +bash util/run-host-e2e.sh --project=chromium tests/Y-kb-management-ux.spec.ts + +# inspect the resolved URL + database mapping without running anything +bash util/run-host-e2e.sh --print + +# linked-workspace token override (long branch names can get truncated) +E2E_WORKSPACE= bash util/run-host-e2e.sh --project=chromium +``` + +The runner installs only the Playwright workspace dependencies on the host, +builds `@klicker-uzh/prisma` and `@klicker-uzh/types` for global setup, maps +the application URLs and seed database to the reachable runtime, and reuses +the host browser cache. Headless runs install only the smaller Chromium shell; +a headed run needs one full Chromium installation on the host. + +The seed database uses the workspace Postgres container's OrbStack host name. +Node Postgres cannot negotiate libpq direct TLS through the Traefik database +route; that route remains correct for psql and other libpq tooling. +On another Docker runtime, pass `E2E_DATABASE_URL` for a disposable database +that is reachable from the host. +Container-local dependencies stay behind the routed applications. If a future +browser journey needs direct access to another service, expose a host route for +that service instead of running Playwright inside the DevPod. + +The existing global setup resets and reseeds the mapped database. Run the host +runner only against a disposable local test runtime. + +### Legacy host-based stack + Run from repo root. Use Volta when Node/pnpm versions are confusing. ```bash diff --git a/AGENTS.md b/AGENTS.md index 94c8cf5bf3..86368bec0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ Prisma split-schema under `packages/prisma/src/prisma/schema/`. After editing a ### Self-contained devcontainer (recommended) -Clone-and-run via a self-contained devcontainer — no Infisical/Doppler, no EduID, no `/etc/hosts` edits. The container owns the whole stack (Node 24 + pnpm toolchain, Postgres, 3× Redis, MailHog, Hatchet) and runs **all core apps in ONE container** via `turbo dev`. Run pnpm/prisma/tests **inside the container**, never on the host. +Clone-and-run via a self-contained devcontainer — no Infisical/Doppler, no EduID, no `/etc/hosts` edits. The container owns the whole stack (Node 24 + pnpm toolchain, Postgres, 3× Redis, MailHog, Hatchet) and runs **all core apps in ONE container** via `turbo dev`. Run pnpm/prisma/unit tests **inside the container**, never on the host. **Exception — Playwright E2E runs on the host**: run `bash util/run-host-e2e.sh --project=chromium tests/.spec.ts` (or `pnpm --filter @klicker-uzh/playwright test:host -- --project=chromium `) — it auto-maps the app URLs and seed database for routed devrouter workspaces, a plain primary devcontainer, or host-run apps, and shares host browser/node caches; never download browsers into a DevPod. Services without direct routes stay inside the container and are exercised through the routed apps; do not move Playwright into the container to reach them. ```bash devrouter ensure . diff --git a/docs/getting-started.md b/docs/getting-started.md index 913b638a4c..736f344ac1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -60,6 +60,7 @@ Clone-and-run via a self-contained devcontainer — no Infisical, no external Ed Use `devrouter workspace up ` from the main repository to create a new worktree. Do not use bare `devpod up` or manual route-token loops; `ensure` owns the persisted identity, Git mount, overlay, aliases, runtime proof, and routes together. 3. Those namespaced hosts only work because `allowedDevOrigins` in `packages/next-config/index.js` is `['**.localhost']` in development (and `undefined` in production) — Next's implicit `*.localhost` matches a single label only. If that glob ever stops covering a worktree host, the symptom is an app that serves HTML but never hydrates, with no obvious error. 3. **Logs:** The dev servers auto-start inside the container. View logs via `devrouter exec . -- tail -f /tmp/dev.log`. +4. **Browser E2E:** Run Playwright from the host with `bash util/run-host-e2e.sh --project=chromium `. The runner detects routed linked and primary checkouts, a plain primary devcontainer, and host-run apps; it maps the app URLs and seed database while reusing the host browser cache. Never install Playwright browser binaries in a DevPod. The existing global setup resets and reseeds the mapped database, so use only a disposable local test runtime. For OpenRouter-backed Chat, inject the shared prototyping key when starting the workspace; never write it into the repository: diff --git a/playwright/package.json b/playwright/package.json index b61b8542bb..831961b2ff 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -17,6 +17,7 @@ "test": "../util/_run_with_infisical.sh --env dev-playwright playwright test", "test:headed": "../util/_run_with_infisical.sh --env dev-playwright playwright test --headed --project=chromium", "test:headed:raw": "playwright test --headed --project=chromium", + "test:host": "bash ../util/run-host-e2e.sh", "test:raw": "playwright test", "test:run": "../util/_run_with_infisical.sh --env dev-playwright playwright test", "test:run:raw": "playwright test", diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md index e808403d90..1eedbbad6f 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-kb-management-ux-audit-and-roadmap.md @@ -314,8 +314,9 @@ Pause before implementation or between slices for a material product/API decisio - [x] S4 secondary configuration implemented locally: chatbot and graph configuration own collapsed disclosures with explicit Configure affordances; graph status, stale, and human-review states remain in the summary, and the expensive graph preview mounts only after expansion. Existing queries, polling, mutations, pointers, and lifecycle contracts are unchanged. The focused Playwright spec covers the English/German workspace, chooser keyboard path, table metadata, and mobile overflow. - [x] Final static review corrections applied locally: file-upload dismissal is locked after upload starts, chooser dismissal is singular, destructive deletion is in the row overflow menu, the `ADDED`-before-Ingest lifecycle is asserted in the focused spec, and the frontend KB conventions are documented. - [x] Static S5 package proof complete: Sol final review passed the exact implementation range through `f817a281dd25ceab33d6d13850b8e6d186911457`, the focused spec covers desktop plus 390px and 320px reflow guards, and the full repository typecheck passes 29/29 Turbo tasks after workspace outputs were generated. -- [x] Root formatting passes. Root lint remains environment-blocked in the unrelated analytics package: Turbo cannot initialize the host uv cache, a direct temporary-cache retry cannot build pandas because the host lacks `llvm-ar`, and the standalone ruff run exposes 97 pre-existing analytics findings outside this change. -- [ ] S5 browser proof remains pending because the exact implementation runtime cannot start under the existing lifecycle lock. +- [x] Root formatting passes. Root lint remains environment-blocked in the unrelated analytics package: the host lacks `llvm-ar`, the DevPod lacks a C compiler for its pandas build, and the standalone ruff run exposes 97 pre-existing analytics findings outside this change. +- [x] Host-side Playwright execution is available through `util/run-host-e2e.sh`: it maps routed worktrees, a plain devcontainer, and host-run apps while keeping browser binaries in the shared host cache. The focused lecturer-login smoke passes against the exact linked workspace. +- [ ] S5 full browser proof remains pending: the KB journey passes creation, chooser focus handling, resource table, and inspector checks, then the document-level 390px/320px reflow assertion detects the known shared Manage-header overflow classified as X1 outside this package. ## Verification plan for implementation diff --git a/util/run-host-e2e.sh b/util/run-host-e2e.sh new file mode 100755 index 0000000000..d57c0e2ebc --- /dev/null +++ b/util/run-host-e2e.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# Host-side Playwright runner for KlickerUZH. +# +# Runs E2E specs from the host against whichever local runtime is reachable +# and maps all test URLs (plus the global-setup seed DATABASE_URL) to it: +# +# linked devrouter linked worktree https://{app}.klicker..localhost +# primary devrouter primary checkout https://{app}.klicker.localhost +# devcontainer plain primary container http://localhost: +# host plain host-run apps http://127.0.0.1: +# +# Browser binaries and node_modules stay on the host (shared pnpm store and +# Playwright's platform cache), so nothing is ever downloaded into DevPods. +# +# Usage: +# bash util/run-host-e2e.sh --print +# bash util/run-host-e2e.sh --project=chromium tests/Y-kb-management-ux.spec.ts +# pnpm --filter @klicker-uzh/playwright test:host -- --project=chromium tests/A-login.spec.ts +# +# Environment overrides: +# E2E_MODE=auto|linked|primary|devcontainer|host +# force a runtime mode (default: auto) +# E2E_WORKSPACE= linked-workspace token; see devrouter workspace ls +# E2E_DATABASE_URL=... full DATABASE_URL override +# E2E_SKIP_INSTALL=1 skip the host dependency bootstrap +# E2E_NO_VERIFY=1 skip reachability probes (mapping inspection only) +# +# Container-local dependencies remain inside the runtime and are exercised +# through the routed applications. Playwright itself always runs on the host. +# The existing Playwright global setup resets and reseeds DATABASE_URL; target +# only a disposable local test database. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEV_ENV_FILE="$REPO_ROOT/.devcontainer/devcontainer.env" + +log() { printf '[host-e2e] %s\n' "$*"; } +die() { printf '[host-e2e] ERROR: %s\n' "$*" >&2; exit 1; } + +PRINT=0 +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --print) PRINT=1 ;; + --) ;; + *) ARGS+=("$1") ;; + esac + shift +done + +# --- runtime detection ------------------------------------------------------ + +reachable() { curl -kfsS --max-time 4 -o /dev/null "$1" 2>/dev/null; } +compose_container() { + docker ps \ + --filter "label=com.docker.compose.project.working_dir=$REPO_ROOT/.devcontainer" \ + --filter "label=com.docker.compose.service=$1" \ + --format '{{.Names}}' 2>/dev/null | head -1 +} +compose_publishes_port() { + local container + container="$(compose_container "$1")" + [[ -n "$container" ]] || return 1 + [[ -n "$(docker port "$container" "$2/tcp" 2>/dev/null)" ]] +} +NO_VERIFY="${E2E_NO_VERIFY:-0}" + +GIT_COMMON="$(git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir)" +IS_LINKED_WORKTREE=0 +if [[ "$GIT_COMMON" != "$REPO_ROOT/.git" ]]; then + IS_LINKED_WORKTREE=1 +fi + +MODE="${E2E_MODE:-auto}" +WORKSPACE="${E2E_WORKSPACE:-}" + +if [[ -z "$WORKSPACE" && ( "$MODE" == linked || "$MODE" == auto ) && "$IS_LINKED_WORKTREE" == 1 ]]; then + BRANCH="$(git -C "$REPO_ROOT" branch --show-current)" + if [[ -z "$BRANCH" ]]; then + die "cannot derive a workspace token from detached HEAD; set E2E_WORKSPACE (see: devrouter workspace ls)" + fi + WORKSPACE="${BRANCH//\//-}" +fi + +case "$MODE" in + auto) + if [[ "$IS_LINKED_WORKTREE" == 1 ]]; then + PROBE="https://api.klicker.${WORKSPACE}.localhost/healthz" + if [[ "$NO_VERIFY" == 1 ]] || reachable "$PROBE"; then + MODE=linked + elif compose_publishes_port app 3002 && reachable http://localhost:3002; then + MODE=devcontainer + elif reachable http://127.0.0.1:3002; then + MODE=host + else + die "linked workspace '$WORKSPACE' is not reachable at $PROBE + start the runtime: devrouter ensure . + wrong token? devrouter workspace ls -> E2E_WORKSPACE= + plain devcontainer: E2E_MODE=devcontainer + host-run apps: E2E_MODE=host + (long branch names can get truncated in the devrouter workspace token)" + fi + elif [[ "$NO_VERIFY" == 1 ]] || reachable https://api.klicker.localhost/healthz; then + MODE=primary + elif compose_publishes_port app 3002 && reachable http://localhost:3002; then + MODE=devcontainer + elif reachable http://127.0.0.1:3000/healthz || reachable http://127.0.0.1:3002; then + MODE=host + else + die "no local runtime detected + primary devcontainer: devrouter ensure . + plain devcontainer: E2E_MODE=devcontainer + host-run apps: pnpm run dev:playwright (then E2E_MODE=host) + force a mode: E2E_MODE=linked|primary|devcontainer|host" + fi + ;; + linked) + if [[ -z "$WORKSPACE" ]]; then + die "E2E_MODE=linked needs a workspace token; set E2E_WORKSPACE (see: devrouter workspace ls)" + fi + if [[ "$NO_VERIFY" != 1 ]]; then + reachable "https://api.klicker.${WORKSPACE}.localhost/healthz" \ + || die "linked workspace '$WORKSPACE' is not reachable; start it with: devrouter ensure ." + fi + ;; + primary) + if [[ "$NO_VERIFY" != 1 ]]; then + reachable https://api.klicker.localhost/healthz \ + || die "primary devcontainer routes are not reachable; start them with: devrouter ensure ." + fi + ;; + devcontainer) + if [[ "$NO_VERIFY" != 1 ]]; then + reachable http://localhost:3002 \ + || die "no Manage app on localhost:3002; start the primary devcontainer or choose another E2E_MODE" + fi + ;; + host) + if [[ "$NO_VERIFY" != 1 ]]; then + reachable http://127.0.0.1:3002 \ + || die "no host-run Manage app on 127.0.0.1:3002; start one with: pnpm run dev:playwright" + fi + ;; + *) + die "E2E_MODE must be auto, linked, primary, devcontainer, or host" + ;; +esac + +# --- URL and database mapping ----------------------------------------------- + +case "$MODE" in + linked) + URL_STUDENT="https://pwa.klicker.${WORKSPACE}.localhost" + URL_MANAGE="https://manage.klicker.${WORKSPACE}.localhost" + URL_CONTROL="https://control.klicker.${WORKSPACE}.localhost" + URL_CHAT="https://chat.klicker.${WORKSPACE}.localhost" + URL_AUTH="https://auth.klicker.${WORKSPACE}.localhost" + APP_ORIGIN_AUTH="${URL_AUTH}" + COOKIE_DOMAIN="klicker.${WORKSPACE}.localhost" + DB_ROUTE_HOST="db.klicker.${WORKSPACE}.localhost" + ;; + primary) + URL_STUDENT="https://pwa.klicker.localhost" + URL_MANAGE="https://manage.klicker.localhost" + URL_CONTROL="https://control.klicker.localhost" + URL_CHAT="https://chat.klicker.localhost" + URL_AUTH="https://auth.klicker.localhost" + APP_ORIGIN_AUTH="${URL_AUTH}" + COOKIE_DOMAIN="klicker.localhost" + DB_ROUTE_HOST="db.klicker.localhost" + ;; + devcontainer) + URL_STUDENT="http://localhost:3001" + URL_MANAGE="http://localhost:3002" + URL_CONTROL="http://localhost:3003" + URL_CHAT="http://localhost:3004" + URL_AUTH="http://localhost:3010" + APP_ORIGIN_AUTH="${URL_AUTH}" + COOKIE_DOMAIN="localhost" + DB_HOSTPORT="localhost:5432" + DB_PARAMS="" + ;; + host) + URL_STUDENT="http://127.0.0.1:3001" + URL_MANAGE="http://127.0.0.1:3002" + URL_CONTROL="http://127.0.0.1:3003" + URL_CHAT="http://127.0.0.1:3004" + URL_AUTH="http://127.0.0.1:3010" + DB_HOSTPORT="127.0.0.1:5432" + DB_PARAMS="" + ;; +esac +URL_STUDENT_LOGIN="${URL_STUDENT}/login" + +# --- seed database target --------------------------------------------------- +# +# node-postgres cannot send libpq's sslnegotiation=direct handshake, so the +# Traefik SNI-routed db.*.localhost URL deadlocks its TLS negotiation (the +# routed URL stays correct for psql/libpq tooling). The workspace Postgres is +# a plain container, and OrbStack exposes containers to the host as +# .orb.local — connect there directly instead. +DB_HOSTPORT="" +DB_PARAMS="" +if [[ "$MODE" == linked || "$MODE" == primary ]]; then + POSTGRES_CONTAINER="$(compose_container postgres)" + if [[ -n "$POSTGRES_CONTAINER" ]]; then + DB_HOSTPORT="${POSTGRES_CONTAINER}.orb.local:5432" + if [[ -z "${E2E_DATABASE_URL:-}" && "$NO_VERIFY" != 1 ]] \ + && command -v nc >/dev/null 2>&1 \ + && ! nc -z -w 3 "${POSTGRES_CONTAINER}.orb.local" 5432 \ + >/dev/null 2>&1; then + die "workspace Postgres is not reachable at $DB_HOSTPORT + OrbStack exposes this address automatically; on another Docker runtime, + set E2E_DATABASE_URL to a host-reachable disposable PostgreSQL database" + fi + else + die "workspace Postgres container not found + the routed database URL at ${DB_ROUTE_HOST} requires libpq direct TLS, + which Node Postgres cannot negotiate; set E2E_DATABASE_URL to a + host-reachable PostgreSQL endpoint instead" + fi +elif [[ "$MODE" == devcontainer ]]; then + DB_HOSTPORT="localhost:5432" +else + DB_HOSTPORT="127.0.0.1:5432" +fi + +# Credentials and database name come from the committed dev-only env file, so +# this script never duplicates them; only the host:port and TLS mode change. +DB_TEMPLATE="$(grep -E '^DATABASE_URL=' "$DEV_ENV_FILE" | head -1 | cut -d= -f2-)" +if [[ ! "$DB_TEMPLATE" =~ ^postgres(ql)?://([^:/@]+):([^@/]+)@[^/]+/(.+)$ ]]; then + die "cannot parse DATABASE_URL from $DEV_ENV_FILE" +fi +DB_USER="${BASH_REMATCH[2]}" +DB_PASS="${BASH_REMATCH[3]}" +DB_NAME="${BASH_REMATCH[4]}" +DATABASE_URL="postgres://${DB_USER}:${DB_PASS}@${DB_HOSTPORT}/${DB_NAME}${DB_PARAMS}" +if [[ -n "${E2E_DATABASE_URL:-}" ]]; then + DATABASE_URL="$E2E_DATABASE_URL" +fi + +APP_SECRET="$(grep -E '^APP_SECRET=' "$DEV_ENV_FILE" | head -1 | cut -d= -f2-)" +if [[ -z "$APP_SECRET" ]]; then + APP_SECRET=abcd +fi + +if [[ "$PRINT" == 1 ]]; then + log "mode: $MODE" + log "workspace: ${WORKSPACE:-(none)}" + log "URL_STUDENT: $URL_STUDENT" + log "URL_MANAGE: $URL_MANAGE" + log "URL_CONTROL: $URL_CONTROL" + log "URL_CHAT: $URL_CHAT" + log "URL_AUTH: $URL_AUTH" + log "APP_ORIGIN_AUTH: ${APP_ORIGIN_AUTH:-(default http://127.0.0.1:3010)}" + log "COOKIE_DOMAIN: ${COOKIE_DOMAIN:-(origin-only cookies)}" + log "DATABASE_URL: postgres://${DB_USER}:***@${DB_HOSTPORT}/${DB_NAME}${DB_PARAMS}" + log "APP_SECRET: (from devcontainer.env)" + log "skip probes: $NO_VERIFY" + exit 0 +fi + +export URL_STUDENT URL_STUDENT_LOGIN URL_MANAGE URL_CONTROL URL_CHAT URL_AUTH +export APP_ORIGIN_AUTH COOKIE_DOMAIN +export PLAYWRIGHT_BASE_URL="$URL_STUDENT" +export DATABASE_URL APP_SECRET + +# --- host bootstrap --------------------------------------------------------- + +if command -v volta >/dev/null 2>&1; then + PNPM=(volta run pnpm) +else + PNPM=(pnpm) +fi + +if [[ "${E2E_SKIP_INSTALL:-0}" != 1 ]]; then + if [[ ! -d "$REPO_ROOT/node_modules/@playwright" && ! -d "$REPO_ROOT/playwright/node_modules/@playwright/test" ]]; then + log "host node_modules missing -> filtered install (one-time per worktree; shared pnpm store)" + (cd "$REPO_ROOT" && "${PNPM[@]}" install --filter '@klicker-uzh/playwright...' --frozen-lockfile) + fi + if [[ ! -f "$REPO_ROOT/packages/prisma/dist/index.js" || ! -d "$REPO_ROOT/packages/prisma/src/prisma/client" ]]; then + log "building @klicker-uzh/prisma and @klicker-uzh/types (needed by global-setup seeding)" + (cd "$REPO_ROOT" && "${PNPM[@]}" --filter '@klicker-uzh/prisma' run build) + (cd "$REPO_ROOT" && "${PNPM[@]}" --filter '@klicker-uzh/types' run build) + fi + # Headless Chromium launches the smaller headless shell; --only-shell avoids + # the full Chrome-for-Testing download. A --headed run needs the full + # browser once: pnpm --filter @klicker-uzh/playwright exec playwright install chromium + log "ensuring host headless Chromium (shared Playwright browser cache)" + (cd "$REPO_ROOT" && "${PNPM[@]}" --filter '@klicker-uzh/playwright' exec playwright install --only-shell chromium) +fi + +# --- run -------------------------------------------------------------------- + +log "mode=$MODE workspace=${WORKSPACE:-(none)} manage=$URL_MANAGE db=${DB_HOSTPORT}" +log "global setup will reset and reseed the mapped local database" +(cd "$REPO_ROOT" && "${PNPM[@]}" --filter '@klicker-uzh/playwright' exec playwright test ${ARGS[@]+"${ARGS[@]}"}) From 25a32cb9f1943629337bc18f22ec39d6c93024d2 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 11:00:34 +0200 Subject: [PATCH 15/24] docs(project): record KB UX PR metadata --- ...map.md => 2026-08-24-pr-5540-kb-management-ux-plan.md} | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) rename project/{2026-08-24-kb-management-ux-audit-and-roadmap.md => 2026-08-24-pr-5540-kb-management-ux-plan.md} (99%) diff --git a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md b/project/2026-08-24-pr-5540-kb-management-ux-plan.md similarity index 99% rename from project/2026-08-24-kb-management-ux-audit-and-roadmap.md rename to project/2026-08-24-pr-5540-kb-management-ux-plan.md index 1eedbbad6f..fcaa19b8e1 100644 --- a/project/2026-08-24-kb-management-ux-audit-and-roadmap.md +++ b/project/2026-08-24-pr-5540-kb-management-ux-plan.md @@ -1,6 +1,6 @@ # Knowledge Base management UX audit and improvement roadmap -Status: approved execution plan; implementation in progress +Status: draft PR published; implementation complete; final review and browser-proof blockers remain Date: 2026-08-24 @@ -8,9 +8,11 @@ Base snapshot: `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1` Plan branch: `rs/kb-management-ux` -Target branch: `v3-ai` +PR base: `feat/kb-graph-lifecycle` -PR: not created +Ultimate target: `v3-ai` + +PR: [#5540](https://github.com/uzh-bf/klicker-uzh/pull/5540) Related history: published #5424 (`feat/kb-graph-lifecycle`); no sibling PR changes are in scope. From 42773ea45316107bbd48b8c3082884c5a224fc1a Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 15:43:44 +0200 Subject: [PATCH 16/24] fix(dev): harden local runtime for KB verification --- .devcontainer/README.md | 6 +- .devcontainer/post-create.sh | 1 + .devcontainer/post-start.sh | 66 ++- .gitignore | 1 + docs/getting-started.md | 2 + package.json | 2 + ...026-08-24-pr-5540-kb-management-ux-plan.md | 2 + util/dev-runtime.sh | 488 ++++++++++++++++++ util/test-dev-runtime.sh | 207 ++++++++ 9 files changed, 768 insertions(+), 7 deletions(-) create mode 100755 util/dev-runtime.sh create mode 100755 util/test-dev-runtime.sh diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 86315b08fd..0bad38c53e 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -169,7 +169,11 @@ replacement. HTTP readiness remains in `devrouter ensure .`; the root build script forces production mode even though the live container exports `NODE_ENV=development`. Rerun ensure after `pnpm run build` so stale Next.js dev output can trigger the single -container-recreate budget. +container-recreate budget. The repository runtime guard also fingerprints +dependencies, clears only owned `.next/dev` output on an exact dependency +change, and checks semantic readiness for each Next.js app. If a route returns +the known stale `404` HTML response, it requests one bounded cache repair and +rechecks the apps; unexpected responses fail closed without deleting caches. The image also carries uv `0.11.12` and selects Python 3.12, matching the analytics image and lint CI so the root quality gate runs inside the container. diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 463bf05789..bd500e3e3d 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -32,6 +32,7 @@ retry() { echo "[post-create] Installing dependencies (pnpm)..." pnpm install --no-frozen-lockfile +bash ./util/dev-runtime.sh stamp-dependencies # Build the workspace PACKAGES (graphql, prisma, util, markdown, transactional, # types, i18n, ...) the apps import — turbo orders them by their dep graph, and diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 4831dad2c2..71f4c312b8 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -124,12 +124,66 @@ done # Run every routed app, both Hatchet workers, and both internal MCP services # without Infisical. Devrouter owns generic locking, process-group identity, # and bounded replacement; this repository owns only the application command -# and environment above. -"$DEVROUTER_PROCESS_HELPER" ensure \ - --name klicker-dev \ - --match 'turbo run dev' \ - --log /tmp/dev.log \ - -- pnpm run dev:container +# and environment above. The runtime wrapper detects dependency changes and +# repairs confirmed stale Next.js dev output once before failing closed. +start_managed_runtime() { + local runtime_fingerprint runtime_generation + + runtime_fingerprint="$(bash ./util/dev-runtime.sh fingerprint)" + runtime_generation="$(bash ./util/dev-runtime.sh generation)" + "$DEVROUTER_PROCESS_HELPER" ensure \ + --name klicker-dev \ + --match 'turbo run dev' \ + --log /tmp/dev.log \ + -- bash ./util/dev-runtime.sh start "$runtime_fingerprint" "$runtime_generation" \ + -- pnpm run dev:container +} + +start_managed_runtime + +STALE_NEXT_APPS=() +run_readiness_pass() { + local app status=0 + + STALE_NEXT_APPS=() + for app in auth chat frontend-control frontend-manage frontend-pwa; do + status=0 + bash ./util/dev-runtime.sh wait-app "$app" || status=$? + if [ "$status" -eq 20 ]; then + STALE_NEXT_APPS+=("$app") + elif [ "$status" -ne 0 ]; then + echo "[post-start] ERROR: $app failed semantic readiness; no cache cleanup was attempted." >&2 + echo '[post-start] Inspect /tmp/dev.log for the application failure.' >&2 + return "$status" + fi + done + + if [ "${#STALE_NEXT_APPS[@]}" -gt 0 ]; then + echo "[post-start] Confirmed stale Next.js route state: ${STALE_NEXT_APPS[*]}." >&2 + return 20 + fi + return 0 +} + +READINESS_STATUS=0 +run_readiness_pass || READINESS_STATUS=$? +if [ "$READINESS_STATUS" -eq 20 ]; then + echo "[post-start] Repairing the confirmed stale .next caches once: ${STALE_NEXT_APPS[*]}." + for app in "${STALE_NEXT_APPS[@]}"; do + bash ./util/dev-runtime.sh request-repair "$app" + done + start_managed_runtime + + READINESS_STATUS=0 + run_readiness_pass || READINESS_STATUS=$? + if [ "$READINESS_STATUS" -ne 0 ]; then + echo '[post-start] ERROR: The runtime remained unhealthy after its one repair attempt.' >&2 + echo '[post-start] Inspect /tmp/dev.log; no further cache cleanup was attempted.' >&2 + exit 1 + fi +elif [ "$READINESS_STATUS" -ne 0 ]; then + exit 1 +fi if [ -s /etc/devrouter/mkcert-rootCA.pem ]; then cat <&2 + exit 1 +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "Required command is unavailable: $1" +} + +hash_stream() { + sha256sum | awk '{print $1}' +} + +emit_file_identity() { + local file="$1" + local relative digest + + [ -f "$file" ] || die "Required fingerprint input is missing: $file" + relative="${file#"$ROOT/"}" + digest="$(sha256sum "$file")" + digest="${digest%% *}" + printf 'file\0%s\0sha256\0%s\0' "$relative" "$digest" +} + +dependency_files() { + local relative + + for relative in package.json pnpm-lock.yaml pnpm-workspace.yaml; do + [ -f "$ROOT/$relative" ] || die "Required dependency input is missing: $relative" + printf '%s\n' "$ROOT/$relative" + done + + find "$ROOT/apps" "$ROOT/packages" \ + -mindepth 2 -maxdepth 2 -type f -name package.json -print | + LC_ALL=C sort +} + +dependency_fingerprint() { + { + printf 'format\0klicker-dependencies-v1\0' + while IFS= read -r file; do + emit_file_identity "$file" + done < <(dependency_files) + } | hash_stream +} + +git_head() { + if [ -n "${KLICKER_DEV_RUNTIME_GIT_HEAD:-}" ]; then + printf '%s\n' "$KLICKER_DEV_RUNTIME_GIT_HEAD" + return + fi + + git -C "$ROOT" rev-parse HEAD +} + +next_structure_paths() { + local app route_root + + for app in "${NEXT_APPS[@]}"; do + for route_root in "$ROOT/apps/$app/src/app" "$ROOT/apps/$app/src/pages"; do + [ -d "$route_root" ] || continue + find "$route_root" -type f -print + done + done | LC_ALL=C sort +} + +next_configuration_files() { + local app app_root + + for app in "${NEXT_APPS[@]}"; do + app_root="$ROOT/apps/$app" + find "$app_root" -maxdepth 2 -type f \ + \( -name 'next.config.*' -o -name 'package.json' -o \ + -name 'tsconfig*.json' -o -name 'proxy.*' -o -name 'middleware.*' \) \ + -print + done | LC_ALL=C sort -u +} + +runtime_fingerprint() { + local head path + + head="$(git_head)" + [[ "$head" =~ ^[a-fA-F0-9]{40,64}$ ]] || die "Git HEAD is not a commit digest." + + { + printf 'format\0klicker-dev-runtime-v1\0' + printf 'git-head\0%s\0' "$head" + printf 'dependencies\0%s\0' "$(dependency_fingerprint)" + printf 'node\0%s\0' "$(node --version)" + printf 'pnpm\0%s\0' "$(pnpm --version)" + emit_file_identity "$SCRIPT_PATH" + while IFS= read -r path; do + printf 'route-path\0%s\0' "${path#"$ROOT/"}" + done < <(next_structure_paths) + while IFS= read -r path; do + emit_file_identity "$path" + done < <(next_configuration_files) + } | hash_stream +} + +write_atomic() { + local path="$1" + local value="$2" + local temporary + + mkdir -p "$(dirname "$path")" + temporary="$(mktemp "${path}.tmp.XXXXXX")" + printf '%s\n' "$value" >"$temporary" + mv "$temporary" "$path" +} + +read_generation() { + local generation=0 + + if [ -f "$GENERATION_FILE" ]; then + IFS= read -r generation <"$GENERATION_FILE" || true + fi + [[ "$generation" =~ ^[0-9]+$ ]] || die "Runtime generation is invalid." + printf '%s\n' "$generation" +} + +valid_next_app() { + local candidate="$1" + local app + + for app in "${NEXT_APPS[@]}"; do + [ "$candidate" = "$app" ] && return 0 + done + return 1 +} + +probe_url() { + case "$1" in + auth) echo 'http://localhost:3010/' ;; + chat) echo "$CHAT_PROBE_URL" ;; + frontend-control) echo 'http://localhost:3003/login' ;; + frontend-manage) echo 'http://localhost:3002/login' ;; + frontend-pwa) echo 'http://localhost:3001/login' ;; + *) return 1 ;; + esac +} + +# Chat proves its nested dynamic API route graph through the authentication +# contract above. The other apps prove their static route table through a +# committed shell page that renders HTML without database content, so a 404 +# there can never be a legitimate data-driven miss. +probe_mode() { + case "$1" in + chat) echo 'auth-json' ;; + auth | frontend-control | frontend-manage | frontend-pwa) + echo 'html-shell' + ;; + *) return 1 ;; + esac +} + +request_repair() { + local app="$1" generation pending updated + + valid_next_app "$app" || die "Unsupported Next.js repair target: $app" + require_tool flock + mkdir -p "$STATE_DIR" + exec 9>"$STATE_DIR/lock" + flock -w 10 9 || die "Timed out waiting for the runtime-state lock." + + generation="$(read_generation)" + updated="$app" + if [ -s "$REPAIR_REQUEST_FILE" ]; then + while IFS= read -r pending; do + valid_next_app "$pending" || + die "Invalid pending repair target: $pending." + [ "$pending" = "$app" ] || updated="$updated"$'\n'"$pending" + done <"$REPAIR_REQUEST_FILE" + fi + write_atomic "$REPAIR_REQUEST_FILE" "$updated" + write_atomic "$GENERATION_FILE" "$((generation + 1))" + echo "[dev-runtime] Requested one full .next repair for $app." +} + +stamp_dependencies() { + mkdir -p "$ROOT/node_modules" + write_atomic "$DEPENDENCY_STAMP_FILE" "$(dependency_fingerprint)" +} + +ensure_dependencies() { + local current expected="" + + current="$(dependency_fingerprint)" + if [ -f "$DEPENDENCY_STAMP_FILE" ]; then + IFS= read -r expected <"$DEPENDENCY_STAMP_FILE" || true + fi + + if [ "$current" = "$expected" ]; then + echo '[dev-runtime] Dependency volume matches the current workspace.' + return + fi + + echo '[dev-runtime] Dependency inputs changed; running frozen pnpm install.' + (cd "$ROOT" && pnpm install --frozen-lockfile) + write_atomic "$DEPENDENCY_STAMP_FILE" "$current" +} + +remove_next_dir() { + local target="$1" + local allowed=false app next_dir + + for app in "${NEXT_APPS[@]}"; do + next_dir="$ROOT/apps/$app/.next" + if [ "$target" = "$next_dir" ] || [ "$target" = "$next_dir/dev" ]; then + allowed=true + [ ! -L "$next_dir" ] || die "Refusing symlinked Next.js cache: $next_dir" + [ ! -L "$target" ] || die "Refusing symlinked Next.js cache: $target" + break + fi + done + + [ "$allowed" = true ] || die "Refusing unexpected cache target: $target" + [ -e "$target" ] || return 0 + rm -rf -- "$target" + echo "[dev-runtime] Removed generated cache: ${target#"$ROOT/"}" +} + +apply_cache_policy() { + local app repair_target + + for app in "${NEXT_APPS[@]}"; do + remove_next_dir "$ROOT/apps/$app/.next/dev" + done + + if [ -f "$REPAIR_REQUEST_FILE" ]; then + while IFS= read -r repair_target; do + valid_next_app "$repair_target" || + die "Invalid pending repair target: $repair_target." + remove_next_dir "$ROOT/apps/$repair_target/.next" + done <"$REPAIR_REQUEST_FILE" + rm -f "$REPAIR_REQUEST_FILE" + fi +} + +classify_response() { + local mode="$1" status="$2" + local content_type="${3,,}" + + if [ "$mode" = 'auth-json' ]; then + if [ "$status" = '401' ] && [[ "$content_type" == application/json* ]]; then + echo "ready: HTTP $status $content_type" + return 0 + fi + elif [ "$mode" = 'html-shell' ]; then + if [[ "$status" =~ ^[23][0-9][0-9]$ ]] && + [[ "$content_type" == text/html* ]]; then + echo "ready: HTTP $status $content_type" + return 0 + fi + # Next.js redirect responses often have no body and no content-type; the + # redirect itself proves the committed shell route resolved. + if [[ "$status" =~ ^3[0-9][0-9]$ ]]; then + echo "ready: HTTP $status redirect" + return 0 + fi + else + die "Unknown probe mode: $mode." + fi + + if [ "$status" = '404' ] && [[ "$content_type" == text/html* ]]; then + echo "stale: HTTP $status $content_type" + return "$STALE_STATUS" + fi + echo "unexpected: HTTP $status ${content_type:-unknown-content-type}" + return "$UNEXPECTED_STATUS" +} + +probe_app() { + local app="$1" mode url response status content_type + + mode="$(probe_mode "$app")" || die "No probe contract is defined for: $app" + url="$(probe_url "$app")" || die "No probe URL is defined for: $app" + require_tool curl + if ! response="$(curl --silent --show-error --output /dev/null \ + --write-out $'%{http_code}\t%{content_type}' \ + --connect-timeout 2 --max-time 15 --noproxy '*' \ + "$url" 2>/dev/null)"; then + echo "waiting: $app is not accepting connections" + return "$WAITING_STATUS" + fi + + status="${response%%$'\t'*}" + content_type="${response#*$'\t'}" + classify_response "$mode" "$status" "$content_type" +} + +wait_for_app() { + local app="$1" + local attempt observation status=0 last_observation='' + local stale_count=0 unexpected_count=0 + + require_tool sleep + echo "[dev-runtime] Waiting for the $app readiness contract..." + for ((attempt = 1; attempt <= 90; attempt++)); do + status=0 + observation="$(probe_app "$app")" || status=$? + if [ "$observation" != "$last_observation" ]; then + echo "[dev-runtime] $observation" + last_observation="$observation" + fi + + case "$status" in + 0) + echo "[dev-runtime] $app readiness contract is satisfied." + return 0 + ;; + "$STALE_STATUS") + stale_count=$((stale_count + 1)) + unexpected_count=0 + ;; + "$WAITING_STATUS") + stale_count=0 + unexpected_count=0 + ;; + "$UNEXPECTED_STATUS") + stale_count=0 + unexpected_count=$((unexpected_count + 1)) + ;; + *) + die "$app probe returned unsupported status $status." + ;; + esac + + if [ "$attempt" -ge 10 ] && [ "$stale_count" -ge 5 ]; then + echo "[dev-runtime] Confirmed stale $app route state." >&2 + return "$STALE_STATUS" + fi + if [ "$attempt" -ge 10 ] && [ "$unexpected_count" -ge 3 ]; then + echo "[dev-runtime] $app returned a stable unexpected response; no cache was removed." >&2 + return "$UNEXPECTED_STATUS" + fi + [ "$attempt" -eq 90 ] || sleep 1 + done + + echo "[dev-runtime] $app did not satisfy its readiness contract within 90 seconds." >&2 + return 1 +} + +doctor() { + local app observation status=0 unhealthy=0 + local any_stale=false any_unexpected=false + + for app in "${NEXT_APPS[@]}"; do + status=0 + observation="$(probe_app "$app")" || status=$? + if [ "$status" -eq 0 ]; then + echo "[dev-runtime] $app healthy: $observation" + continue + fi + + unhealthy=1 + echo "[dev-runtime] ERROR: $app unhealthy: $observation" >&2 + if [ "$status" -eq "$STALE_STATUS" ]; then + any_stale=true + else + any_unexpected=true + fi + done + + if [ "$any_stale" = true ]; then + echo '[dev-runtime] Run devrouter ensure . on the host to apply the bounded repair.' >&2 + fi + if [ "$any_unexpected" = true ]; then + echo '[dev-runtime] No cache was removed. Inspect /tmp/dev.log for the application failure.' >&2 + fi + return "$unhealthy" +} + +start_runtime() { + local expected_fingerprint="$1" + local expected_generation="$2" + shift 2 + + [ "${1:-}" = '--' ] || die "start requires -- before the runtime command." + shift + [ "$#" -gt 0 ] || die "start requires a runtime command." + [[ "$expected_fingerprint" =~ ^[a-fA-F0-9]{64}$ ]] || + die "Expected runtime fingerprint is invalid." + [[ "$expected_generation" =~ ^[0-9]+$ ]] || + die "Expected runtime generation is invalid." + [ "$expected_fingerprint" = "$(runtime_fingerprint)" ] || + die "Runtime inputs changed before process start; rerun devrouter ensure." + [ "$expected_generation" = "$(read_generation)" ] || + die "Runtime generation changed before process start; rerun devrouter ensure." + + ensure_dependencies + apply_cache_policy + exec "$@" +} + +usage() { + cat <<'EOF' +Usage: + util/dev-runtime.sh fingerprint + util/dev-runtime.sh dependency-fingerprint + util/dev-runtime.sh generation + util/dev-runtime.sh stamp-dependencies + util/dev-runtime.sh ensure-dependencies + util/dev-runtime.sh request-repair + util/dev-runtime.sh start -- [args...] + util/dev-runtime.sh classify-response + util/dev-runtime.sh probe-app + util/dev-runtime.sh wait-app + util/dev-runtime.sh doctor +EOF +} + +main() { + require_tool sha256sum + require_tool awk + require_tool find + require_tool sort + require_tool mktemp + + case "${1:-}" in + fingerprint) + runtime_fingerprint + ;; + dependency-fingerprint) + dependency_fingerprint + ;; + generation) + read_generation + ;; + stamp-dependencies) + stamp_dependencies + ;; + ensure-dependencies) + ensure_dependencies + ;; + request-repair) + [ "$#" -eq 2 ] || die "request-repair requires one app name." + request_repair "$2" + ;; + start) + [ "$#" -ge 5 ] || die "start requires identity and a command." + shift + start_runtime "$@" + ;; + classify-response) + [ "$#" -eq 4 ] || die "classify-response requires mode, status, and content type." + classify_response "$2" "$3" "$4" + ;; + probe-app) + [ "$#" -eq 2 ] || die "probe-app requires one app name." + probe_app "$2" + ;; + wait-app) + [ "$#" -eq 2 ] || die "wait-app requires one app name." + wait_for_app "$2" + ;; + doctor) + [ "$#" -eq 1 ] || die "doctor takes no arguments." + doctor + ;; + --help|-h) + usage + ;; + *) + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/util/test-dev-runtime.sh b/util/test-dev-runtime.sh new file mode 100755 index 0000000000..9e836bd71b --- /dev/null +++ b/util/test-dev-runtime.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME_SCRIPT="$REPO_ROOT/util/dev-runtime.sh" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +fail() { + echo "[test-dev-runtime] FAIL: $*" >&2 + exit 1 +} + +assert_equal() { + [ "$1" = "$2" ] || fail "expected '$1' to equal '$2'" +} + +assert_not_equal() { + [ "$1" != "$2" ] || fail "expected values to differ" +} + +assert_exists() { + [ -e "$1" ] || fail "expected path to exist: $1" +} + +assert_absent() { + [ ! -e "$1" ] || fail "expected path to be absent: $1" +} + +write_file() { + local path="$1" + local content="$2" + + mkdir -p "$(dirname "$path")" + printf '%s\n' "$content" >"$path" +} + +ROOT="$TEST_ROOT/repo" +FAKE_BIN="$TEST_ROOT/bin" +INSTALL_LOG="$TEST_ROOT/install.log" +NEXT_APPS=(auth chat frontend-control frontend-manage frontend-pwa) +mkdir -p "$ROOT/node_modules" "$FAKE_BIN" + +write_file "$ROOT/package.json" '{"packageManager":"pnpm@11.5.0"}' +write_file "$ROOT/pnpm-lock.yaml" 'lockfileVersion: 9' +write_file "$ROOT/pnpm-workspace.yaml" 'packages: [apps/*, packages/*]' +write_file "$ROOT/packages/example/package.json" '{"name":"example"}' + +for app in "${NEXT_APPS[@]}"; do + write_file "$ROOT/apps/$app/package.json" "{\"name\":\"$app\"}" + write_file "$ROOT/apps/$app/next.config.mjs" 'export default {}' +done +write_file "$ROOT/apps/chat/src/app/api/example/route.ts" 'export const GET = true' +write_file "$ROOT/apps/auth/src/pages/index.tsx" 'export default true' + +write_file "$FAKE_BIN/pnpm" '#!/usr/bin/env bash +if [ "${1:-}" = "--version" ]; then + echo "11.5.0" + exit 0 +fi +printf "install\n" >>"$KLICKER_TEST_INSTALL_LOG"' +chmod +x "$FAKE_BIN/pnpm" + +export PATH="$FAKE_BIN:$PATH" +export KLICKER_TEST_INSTALL_LOG="$INSTALL_LOG" +export KLICKER_DEV_RUNTIME_ROOT="$ROOT" +export KLICKER_DEV_RUNTIME_GIT_HEAD=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +base_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +write_file "$ROOT/apps/chat/src/app/api/example/route.ts" 'export const GET = false' +content_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +assert_equal "$base_fingerprint" "$content_fingerprint" + +write_file "$ROOT/apps/chat/src/app/api/added/route.ts" 'export const GET = true' +path_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +assert_not_equal "$base_fingerprint" "$path_fingerprint" + +write_file "$ROOT/apps/chat/next.config.mjs" 'export default { reactStrictMode: true }' +config_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +assert_not_equal "$path_fingerprint" "$config_fingerprint" + +bash "$RUNTIME_SCRIPT" ensure-dependencies >/dev/null +bash "$RUNTIME_SCRIPT" ensure-dependencies >/dev/null +assert_equal "$(wc -l <"$INSTALL_LOG" | tr -d ' ')" '1' + +write_file "$ROOT/pnpm-lock.yaml" 'lockfileVersion: 9.1' +bash "$RUNTIME_SCRIPT" ensure-dependencies >/dev/null +assert_equal "$(wc -l <"$INSTALL_LOG" | tr -d ' ')" '2' + +for app in "${NEXT_APPS[@]}"; do + write_file "$ROOT/apps/$app/.next/dev/cache.bin" 'development cache' + write_file "$ROOT/apps/$app/.next/production.bin" 'production cache' +done + +runtime_fingerprint="$(bash "$RUNTIME_SCRIPT" fingerprint)" +bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 0 -- true +for app in "${NEXT_APPS[@]}"; do + assert_absent "$ROOT/apps/$app/.next/dev" + assert_exists "$ROOT/apps/$app/.next/production.bin" +done + +bash "$RUNTIME_SCRIPT" request-repair chat >/dev/null +assert_equal "$(bash "$RUNTIME_SCRIPT" generation)" '1' +write_file "$ROOT/apps/chat/.next/dev/cache.bin" 'stale development cache' +bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 1 -- true +assert_absent "$ROOT/apps/chat/.next" +assert_exists "$ROOT/apps/auth/.next/production.bin" +assert_absent "$ROOT/.devcontainer/.runtime/next-repair-request" + +write_file "$TEST_ROOT/outside-cache/marker" 'must survive' +ln -s "$TEST_ROOT/outside-cache" "$ROOT/apps/chat/.next" +if bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 1 -- true >/dev/null 2>&1; then + fail 'symlinked cache was accepted' +fi +assert_exists "$TEST_ROOT/outside-cache/marker" + +if bash "$RUNTIME_SCRIPT" request-repair unsupported >/dev/null 2>&1; then + fail 'unsupported repair target was accepted' +fi + +# A stale pass can cover several apps at once: every requested app receives a +# full .next repair in one start, untouched apps keep their production output, +# and repeated requests for the same app stay deduplicated. +rm -f "$ROOT/apps/chat/.next" +for app in "${NEXT_APPS[@]}"; do + write_file "$ROOT/apps/$app/.next/dev/cache.bin" 'development cache' + write_file "$ROOT/apps/$app/.next/production.bin" 'production cache' +done + +bash "$RUNTIME_SCRIPT" request-repair frontend-manage >/dev/null +bash "$RUNTIME_SCRIPT" request-repair chat >/dev/null +bash "$RUNTIME_SCRIPT" request-repair chat >/dev/null +assert_equal "$(bash "$RUNTIME_SCRIPT" generation)" '4' +assert_equal \ + "$(LC_ALL=C sort "$ROOT/.devcontainer/.runtime/next-repair-request" | tr '\n' ' ')" \ + 'chat frontend-manage ' +bash "$RUNTIME_SCRIPT" start "$runtime_fingerprint" 4 -- true +assert_absent "$ROOT/apps/chat/.next" +assert_absent "$ROOT/apps/frontend-manage/.next" +assert_exists "$ROOT/apps/auth/.next/production.bin" +assert_exists "$ROOT/apps/frontend-pwa/.next/production.bin" +assert_absent "$ROOT/.devcontainer/.runtime/next-repair-request" + +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response auth-json 401 'application/json; charset=utf-8')" \ + 'ready: HTTP 401 application/json; charset=utf-8' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response auth-json 404 'text/html; charset=utf-8' +)" || classification_status=$? +assert_equal "$classification_status" '20' +assert_equal "$classification_output" 'stale: HTTP 404 text/html; charset=utf-8' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response auth-json 500 application/json +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 500 application/json' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response auth-json 404 application/json +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 404 application/json' + +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response html-shell 200 'text/html; charset=utf-8')" \ + 'ready: HTTP 200 text/html; charset=utf-8' +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response html-shell 307 'text/html')" \ + 'ready: HTTP 307 text/html' +assert_equal \ + "$(bash "$RUNTIME_SCRIPT" classify-response html-shell 307 '')" \ + 'ready: HTTP 307 redirect' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response html-shell 404 'text/html' +)" || classification_status=$? +assert_equal "$classification_status" '20' +assert_equal "$classification_output" 'stale: HTTP 404 text/html' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response html-shell 500 'text/html' +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 500 text/html' + +classification_status=0 +classification_output="$( + bash "$RUNTIME_SCRIPT" classify-response html-shell 200 'application/json' +)" || classification_status=$? +assert_equal "$classification_status" '22' +assert_equal "$classification_output" 'unexpected: HTTP 200 application/json' + +if bash "$RUNTIME_SCRIPT" classify-response unknown-mode 200 'text/html' >/dev/null 2>&1; then + fail 'unknown probe mode was accepted' +fi +if bash "$RUNTIME_SCRIPT" probe-app unsupported >/dev/null 2>&1; then + fail 'app without a probe contract was accepted' +fi + +echo '[test-dev-runtime] PASS' From da6149e2972c10b81fd883b70ba2b1039418883c Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 15:53:14 +0200 Subject: [PATCH 17/24] docs(dev): clarify local cache policy --- .devcontainer/README.md | 9 +++++---- project/2026-08-24-pr-5540-kb-management-ux-plan.md | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 0bad38c53e..d7345f06f9 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -170,10 +170,11 @@ replacement. HTTP readiness remains in the live container exports `NODE_ENV=development`. Rerun ensure after `pnpm run build` so stale Next.js dev output can trigger the single container-recreate budget. The repository runtime guard also fingerprints -dependencies, clears only owned `.next/dev` output on an exact dependency -change, and checks semantic readiness for each Next.js app. If a route returns -the known stale `404` HTML response, it requests one bounded cache repair and -rechecks the apps; unexpected responses fail closed without deleting caches. +dependencies, clears only the five owned `.next/dev` directories on each true +managed start, and checks semantic readiness for each Next.js app. If a route +returns the known stale `404` HTML response, it requests one bounded full-cache +repair for that app and rechecks the apps; unexpected responses fail closed +without deleting caches. The image also carries uv `0.11.12` and selects Python 3.12, matching the analytics image and lint CI so the root quality gate runs inside the container. diff --git a/project/2026-08-24-pr-5540-kb-management-ux-plan.md b/project/2026-08-24-pr-5540-kb-management-ux-plan.md index 59b289ab29..d24ad1a16d 100644 --- a/project/2026-08-24-pr-5540-kb-management-ux-plan.md +++ b/project/2026-08-24-pr-5540-kb-management-ux-plan.md @@ -320,6 +320,7 @@ Pause before implementation or between slices for a material product/API decisio - [x] Host-side Playwright execution is available through `util/run-host-e2e.sh`: it maps routed worktrees, a plain devcontainer, and host-run apps while keeping browser binaries in the shared host cache. The focused lecturer-login smoke passes against the exact linked workspace. - [x] The latest `origin/v3` local-runtime improvement (`2619be5a2`) is selectively adapted without merging unrelated v3 changes: dependency fingerprinting, bounded stale Next.js cache repair, semantic app readiness, `dev:doctor`, and runtime guard tests are preserved alongside the KB/Azurite startup wiring. The exact linked workspace was re-reconciled successfully with `KB_GRAPH_BLOB_HOST_PORT=10004`; all five Next.js readiness contracts passed. Runtime checks pass for `test:dev-runtime`, `dev:doctor`, formatting, the KB package, and Playwright TypeScript. - [x] Direct browser verification against the retained workspace reaches the redesigned authenticated detail page, metadata table, and unified add-resource chooser; Website and Document are available and Video is disabled as “Coming soon”. Desktop and 390px screenshots are captured in `/private/tmp/kb-management-ux-add-desktop.png` and `/private/tmp/kb-management-ux-detail-mobile.png`. +- [x] Sol final review of `42773ea45` returned `DONE_WITH_CONCERNS` for one low-severity README cache-policy wording mismatch; the wording now matches the unconditional `.next/dev` cleanup and bounded full-cache repair behavior. No code, security, architecture, or scope findings remain. - [ ] S5 full browser proof remains pending: the KB journey passes creation, chooser focus handling, resource table, and inspector checks, then the document-level 390px/320px reflow assertion detects the known shared Manage-header overflow classified as X1 outside this package. ## Verification plan for implementation From 0f552deeed4fac8828db653bd173737e8da53600 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 15:55:51 +0200 Subject: [PATCH 18/24] docs(project): record runtime review --- project/2026-08-24-pr-5540-kb-management-ux-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/2026-08-24-pr-5540-kb-management-ux-plan.md b/project/2026-08-24-pr-5540-kb-management-ux-plan.md index d24ad1a16d..22f98a6e76 100644 --- a/project/2026-08-24-pr-5540-kb-management-ux-plan.md +++ b/project/2026-08-24-pr-5540-kb-management-ux-plan.md @@ -320,7 +320,7 @@ Pause before implementation or between slices for a material product/API decisio - [x] Host-side Playwright execution is available through `util/run-host-e2e.sh`: it maps routed worktrees, a plain devcontainer, and host-run apps while keeping browser binaries in the shared host cache. The focused lecturer-login smoke passes against the exact linked workspace. - [x] The latest `origin/v3` local-runtime improvement (`2619be5a2`) is selectively adapted without merging unrelated v3 changes: dependency fingerprinting, bounded stale Next.js cache repair, semantic app readiness, `dev:doctor`, and runtime guard tests are preserved alongside the KB/Azurite startup wiring. The exact linked workspace was re-reconciled successfully with `KB_GRAPH_BLOB_HOST_PORT=10004`; all five Next.js readiness contracts passed. Runtime checks pass for `test:dev-runtime`, `dev:doctor`, formatting, the KB package, and Playwright TypeScript. - [x] Direct browser verification against the retained workspace reaches the redesigned authenticated detail page, metadata table, and unified add-resource chooser; Website and Document are available and Video is disabled as “Coming soon”. Desktop and 390px screenshots are captured in `/private/tmp/kb-management-ux-add-desktop.png` and `/private/tmp/kb-management-ux-detail-mobile.png`. -- [x] Sol final review of `42773ea45` returned `DONE_WITH_CONCERNS` for one low-severity README cache-policy wording mismatch; the wording now matches the unconditional `.next/dev` cleanup and bounded full-cache repair behavior. No code, security, architecture, or scope findings remain. +- [x] Sol final review of `42773ea45` returned `DONE_WITH_CONCERNS` for one low-severity README cache-policy wording mismatch; the wording now matches the unconditional `.next/dev` cleanup and bounded full-cache repair behavior. The same reviewer’s correction pass over `da6149e29` returned `DONE`; no code, security, architecture, or scope findings remain. - [ ] S5 full browser proof remains pending: the KB journey passes creation, chooser focus handling, resource table, and inspector checks, then the document-level 390px/320px reflow assertion detects the known shared Manage-header overflow classified as X1 outside this package. ## Verification plan for implementation From 601d2a6dc2e9d60b375637f7fd7092dcb0711f0a Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 17:35:06 +0200 Subject: [PATCH 19/24] docs(project): record PR publication --- project/2026-08-24-pr-5540-kb-management-ux-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/project/2026-08-24-pr-5540-kb-management-ux-plan.md b/project/2026-08-24-pr-5540-kb-management-ux-plan.md index 22f98a6e76..8fb28980bb 100644 --- a/project/2026-08-24-pr-5540-kb-management-ux-plan.md +++ b/project/2026-08-24-pr-5540-kb-management-ux-plan.md @@ -321,6 +321,7 @@ Pause before implementation or between slices for a material product/API decisio - [x] The latest `origin/v3` local-runtime improvement (`2619be5a2`) is selectively adapted without merging unrelated v3 changes: dependency fingerprinting, bounded stale Next.js cache repair, semantic app readiness, `dev:doctor`, and runtime guard tests are preserved alongside the KB/Azurite startup wiring. The exact linked workspace was re-reconciled successfully with `KB_GRAPH_BLOB_HOST_PORT=10004`; all five Next.js readiness contracts passed. Runtime checks pass for `test:dev-runtime`, `dev:doctor`, formatting, the KB package, and Playwright TypeScript. - [x] Direct browser verification against the retained workspace reaches the redesigned authenticated detail page, metadata table, and unified add-resource chooser; Website and Document are available and Video is disabled as “Coming soon”. Desktop and 390px screenshots are captured in `/private/tmp/kb-management-ux-add-desktop.png` and `/private/tmp/kb-management-ux-detail-mobile.png`. - [x] Sol final review of `42773ea45` returned `DONE_WITH_CONCERNS` for one low-severity README cache-policy wording mismatch; the wording now matches the unconditional `.next/dev` cleanup and bounded full-cache repair behavior. The same reviewer’s correction pass over `da6149e29` returned `DONE`; no code, security, architecture, or scope findings remain. +- [x] The user explicitly authorized normal publication. The exact final head `0f552deeed4fac8828db653bd173737e8da53600` is pushed to `rs/kb-management-ux`, and draft PR #5540 is read back against `feat/kb-graph-lifecycle` at #5424 head `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1`; CI is pending on this head. - [ ] S5 full browser proof remains pending: the KB journey passes creation, chooser focus handling, resource table, and inspector checks, then the document-level 390px/320px reflow assertion detects the known shared Manage-header overflow classified as X1 outside this package. ## Verification plan for implementation From 6638b21b6acb1c4ab5b0c4d8cc1f2e56fb971b77 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 19:19:38 +0200 Subject: [PATCH 20/24] fix(kb): address management UX review feedback --- .../src/components/common/Header.tsx | 29 +++------ .../KnowledgeBaseAddResourceModal.tsx | 61 ++++++++++--------- .../src/components/KnowledgeGraphPanel.tsx | 42 ++++++------- 3 files changed, 61 insertions(+), 71 deletions(-) diff --git a/apps/frontend-manage/src/components/common/Header.tsx b/apps/frontend-manage/src/components/common/Header.tsx index ed474f2ca6..b15d14d160 100644 --- a/apps/frontend-manage/src/components/common/Header.tsx +++ b/apps/frontend-manage/src/components/common/Header.tsx @@ -8,17 +8,17 @@ import { CountCatalogSharingRequestsDocument, GetUserCoursesDocument, GetUserRunningLiveQuizzesDocument, - User, + type User, UserRole, } from '@klicker-uzh/graphql/dist/ops' import { Navigation, - NavigationItemProps, - NavigationMenuItemProps, + type NavigationItemProps, + type NavigationMenuItemProps, } from '@uzh-bf/design-system' -import { useTranslations } from 'next-intl' import Image from 'next/image' import { useRouter } from 'next/router' +import { useTranslations } from 'next-intl' import { useState } from 'react' import { twMerge } from 'tailwind-merge' import SupportModal from './SupportModal' @@ -42,17 +42,6 @@ function Header({ user }: { user?: User | null }): React.ReactElement { const courses = courseData?.userCourses const resourceElements: NavigationMenuItemProps[] = [ - ...(user?.privatePreview - ? [ - { - key: 'knowledge-bases-item', - type: 'link' as const, - label: t('kb.title'), - onClick: () => router.push('/resources/knowledgeBases'), - data: { cy: 'knowledge-bases' }, - }, - ] - : []), { key: 'knowledge-bases-item', type: 'link' as const, @@ -131,7 +120,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { key: 'library-menubar-item', label: t('manage.general.library'), onClick: () => router.push('/'), - active: router.pathname == '/', + active: router.pathname === '/', data: { cy: 'library' }, }, { @@ -139,7 +128,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { key: 'activities-menubar-item', label: t('shared.generic.activities'), onClick: () => router.push('/activities'), - active: router.pathname == '/activities', + active: router.pathname === '/activities', data: { cy: 'activities' }, }, { @@ -147,7 +136,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { key: 'courses-menubar-item', label: t('manage.general.courses'), onClick: () => router.push('/courses'), - active: router.pathname == '/courses', + active: router.pathname === '/courses', data: { cy: 'courses' }, }, @@ -158,7 +147,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { icon: faBolt, active: router.pathname.startsWith('/resources/knowledgeBases') || - router.pathname == '/resources/answerCollections' || + router.pathname === '/resources/answerCollections' || router.pathname === '/resources/chatbots' || router.pathname === '/resources/catalog' || router.pathname === '/resources/userGroups' || @@ -295,7 +284,7 @@ function Header({ user }: { user?: User | null }): React.ReactElement { type: 'link', label: t('shared.generic.logout'), onClick: () => - router.push(process.env.NEXT_PUBLIC_AUTH_URL + '/logout'), + router.push(`${process.env.NEXT_PUBLIC_AUTH_URL}/logout`), data: { cy: 'logout' }, }, ], diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx index b056a83230..04d30d1cf1 100644 --- a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -6,6 +6,12 @@ import KnowledgeBaseUrlForm from './KnowledgeBaseUrlForm' type AddResourceMode = 'chooser' | 'website' | 'document' +const FOCUS_SELECTORS: Record = { + chooser: '[data-cy="choose-kb-resource-website"]', + website: '[data-cy="kb-url-title"]', + document: '[data-cy="kb-file-dropzone"]', +} + function KnowledgeBaseAddResourceModal({ kbId, triggerRef, @@ -73,13 +79,7 @@ function KnowledgeBaseAddResourceModal({ useEffect(() => { const frame = window.requestAnimationFrame(() => { - const selector = - mode === 'chooser' - ? '[data-cy="choose-kb-resource-website"]' - : mode === 'website' - ? '[data-cy="kb-url-title"]' - : '[data-cy="kb-file-dropzone"]' - document.querySelector(selector)?.focus() + document.querySelector(FOCUS_SELECTORS[mode])?.focus() }) return () => window.cancelAnimationFrame(frame) @@ -88,12 +88,34 @@ function KnowledgeBaseAddResourceModal({ const handleResourceCreated = async () => { try { await onResourceCreated() - } finally { - closeModal() + } catch { + console.error('Failed to refresh KB resources after creation', { kbId }) + return } + closeModal() } const isChooser = mode === 'chooser' + const modeTitles: Record = { + chooser: t('kb.addResourceTitle'), + website: t('kb.addWebsite'), + document: t('kb.addDocument'), + } + const resourceForm = + mode === 'website' ? ( + + ) : ( + + ) return (
- ) : mode === 'website' ? ( - ) : ( - + resourceForm )}
) diff --git a/packages/kb-management/src/components/KnowledgeGraphPanel.tsx b/packages/kb-management/src/components/KnowledgeGraphPanel.tsx index 4a6362a8fb..983e4e2af1 100644 --- a/packages/kb-management/src/components/KnowledgeGraphPanel.tsx +++ b/packages/kb-management/src/components/KnowledgeGraphPanel.tsx @@ -25,8 +25,8 @@ import type { KnowledgeGraphDataSource } from '@klicker-uzh/shared-components/sr import { KnowledgeGraphUnavailableError } from '@klicker-uzh/shared-components/src/knowledgeGraph/knowledgeGraphState' import type { KnowledgeGraphResponse } from '@klicker-uzh/types' import { Badge, Button, SelectField, Switch } from '@uzh-bf/design-system' -import { useFormatter, useTranslations } from 'next-intl' import dynamic from 'next/dynamic' +import { useFormatter, useTranslations } from 'next-intl' import React, { useEffect, useMemo, useState } from 'react' const KnowledgeGraphViewer = dynamic( @@ -287,20 +287,22 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { released: t('kb.graphCostStatusReleased'), needsHumanReview: t('kb.graphCostStatusNeedsHumanReview'), } - const graphSummary = - loading && data === undefined - ? t('kb.graphLoading') - : error || config === undefined - ? t('kb.graphLoadError') - : [ - `${t('kb.graphStatusLabel')}: ${statusLabel(config.status, statusLabels)}`, - config.isStale && hasPublishedGraph ? t('kb.graphStale') : null, - config.costStatus === KbGraphCostStatus.NeedsHumanReview - ? costStatusLabel(config.costStatus, costStatusLabels) - : null, - ] - .filter((value): value is string => Boolean(value)) - .join(' · ') + let graphSummary: string + if (loading && data === undefined) { + graphSummary = t('kb.graphLoading') + } else if (error || config === undefined) { + graphSummary = t('kb.graphLoadError') + } else { + graphSummary = [ + `${t('kb.graphStatusLabel')}: ${statusLabel(config.status, statusLabels)}`, + config.isStale && hasPublishedGraph ? t('kb.graphStale') : null, + config.costStatus === KbGraphCostStatus.NeedsHumanReview + ? costStatusLabel(config.costStatus, costStatusLabels) + : null, + ] + .filter((value): value is string => Boolean(value)) + .join(' · ') + } const handleRebuild = async () => { if (isRebuilding || isActive || !config?.isEnabled) return @@ -311,7 +313,7 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { variables: { kbId, qualityTier: selectedTier }, }) await refetch() - } catch (mutationError) { + } catch { console.error('Failed to rebuild KB knowledge graph', { kbId }) setOperationError(t('kb.graphBuildError')) } @@ -339,13 +341,7 @@ function KnowledgeGraphPanel({ kbId }: { kbId: string }) { onToggle={(event) => setDetailsOpen(event.currentTarget.open)} > - - {t('kb.graphTitle')} - +

{t('kb.graphTitle')}

Date: Tue, 25 Aug 2026 19:50:01 +0200 Subject: [PATCH 21/24] test(kb): focus management UX checks on desktop --- playwright/tests/Y-kb-management-ux.spec.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts index 2d0e6c8765..51811e32bf 100644 --- a/playwright/tests/Y-kb-management-ux.spec.ts +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -116,21 +116,7 @@ test.describe('Knowledge base management workspace', () => { fullPage: true, }) - for (const width of [390, 320]) { - await page.setViewportSize({ width, height: 844 }) - await expect - .poll(() => - page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth - ) - ) - .toBe(true) - await page.screenshot({ - path: testInfo.outputPath(`kb-management-en-${width}.png`), - fullPage: true, - }) - } - + await page.setViewportSize({ width: 1440, height: 900 }) await page.goto(`${manageUrl}/de${detailPath}`) await expect(page.getByTestId('knowledge-base-detail')).toBeVisible() await expect(page.getByTestId('add-kb-resource')).toContainText( @@ -140,7 +126,7 @@ test.describe('Knowledge base management workspace', () => { page.getByTestId('kb-chatbot-settings').getByText('Konfigurieren') ).toBeVisible() await page.screenshot({ - path: testInfo.outputPath('kb-management-de-mobile.png'), + path: testInfo.outputPath('kb-management-de-desktop.png'), fullPage: true, }) } finally { From 89fb7d7bb6a6c79d33a6a0a6a7db7e2183ff7612 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 20:45:27 +0200 Subject: [PATCH 22/24] test(kb): stabilize management UX E2E setup --- playwright/tests/Y-kb-management-ux.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts index 51811e32bf..fd87c24436 100644 --- a/playwright/tests/Y-kb-management-ux.spec.ts +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -18,6 +18,7 @@ test.describe('Knowledge base management workspace', () => { await expect( page.getByRole('main').getByRole('heading', { level: 1 }) ).toBeVisible() + await expect(page.getByTestId('knowledge-base-loading')).toBeHidden() await page.getByTestId('create-knowledge-base').click() await page.getByTestId('knowledge-base-name').fill(kbName) From a035b1be66541a3747da570f01204ead96521535 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 21:15:07 +0200 Subject: [PATCH 23/24] fix(kb): keep resource creation modal consistent --- .../KnowledgeBaseAddResourceModal.tsx | 1 - playwright/tests/Y-kb-management-ux.spec.ts | 109 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx index 04d30d1cf1..8f9595dc78 100644 --- a/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx +++ b/packages/kb-management/src/components/KnowledgeBaseAddResourceModal.tsx @@ -90,7 +90,6 @@ function KnowledgeBaseAddResourceModal({ await onResourceCreated() } catch { console.error('Failed to refresh KB resources after creation', { kbId }) - return } closeModal() } diff --git a/playwright/tests/Y-kb-management-ux.spec.ts b/playwright/tests/Y-kb-management-ux.spec.ts index fd87c24436..e5f2899ccc 100644 --- a/playwright/tests/Y-kb-management-ux.spec.ts +++ b/playwright/tests/Y-kb-management-ux.spec.ts @@ -78,14 +78,123 @@ test.describe('Knowledge base management workspace', () => { await expect(modal).toBeHidden() await expect(page.getByTestId('add-kb-resource')).toBeFocused() + let releasePendingUpload = () => {} + let signalUploadStarted = () => {} + let failNextKbMetricsRefresh = false + const pendingUpload = new Promise((resolve) => { + releasePendingUpload = resolve + }) + const uploadStarted = new Promise((resolve) => { + signalUploadStarted = resolve + }) + + await page.route('**/graphql', async (route) => { + const request = route.request() + if (request.method() !== 'POST') { + await route.continue() + return + } + + const operationName = ( + request.postDataJSON() as { operationName?: string } + ).operationName + if (operationName === 'RequestKbFileUpload') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + requestKbFileUpload: { + uploadSasURL: 'https://kb-upload.invalid/?sig=test', + containerName: 'kb', + blobName: 'pending.txt', + }, + }, + }), + }) + return + } + if (operationName === 'ConfirmKbFileUpload') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { confirmKbFileUpload: { id: 'synthetic-resource' } }, + }), + }) + return + } + + if (operationName === 'GetKb' && failNextKbMetricsRefresh) { + failNextKbMetricsRefresh = false + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + errors: [{ message: 'Synthetic metrics refresh failure' }], + }), + }) + return + } + + await route.continue() + }) + await page.route('https://kb-upload.invalid/**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { + 'access-control-allow-headers': '*', + 'access-control-allow-methods': 'PUT, OPTIONS', + 'access-control-allow-origin': '*', + }, + }) + return + } + + signalUploadStarted() + await pendingUpload + await route.fulfill({ + status: 201, + headers: { + 'access-control-allow-origin': '*', + etag: '"synthetic-etag"', + 'last-modified': new Date(0).toUTCString(), + 'x-ms-request-id': 'synthetic-request', + 'x-ms-version': '2025-11-05', + }, + }) + }) + + await page.getByTestId('add-kb-resource').click() + await page.getByTestId('choose-kb-resource-document').click() + await page.getByTestId('kb-file-input').setInputFiles({ + name: 'pending.txt', + mimeType: 'text/plain', + buffer: Buffer.from('pending upload'), + }) + await uploadStarted + await expect(page.getByTestId('close-kb-add-resource-modal')).toHaveCount( + 0 + ) + await expect(page.getByTestId('back-kb-add-resource')).toHaveCount(0) + await page.keyboard.press('Escape') + await expect(modal).toBeVisible() + + releasePendingUpload() + await expect(modal).toBeHidden() + await page.getByTestId('add-kb-resource').click() await page.getByTestId('choose-kb-resource-website').click() await page.getByTestId('kb-url-title').fill(resourceTitle) await page .getByTestId('kb-url') .fill(`https://example.org/${resourceTitle.replaceAll(' ', '-')}`) + failNextKbMetricsRefresh = true await page.getByTestId('add-kb-url-resource').click() await expect(modal).toBeHidden() + await page.reload() + await expect(detail).toBeVisible() const resourceTable = page.getByRole('table') await expect(resourceTable).toBeVisible() From d9747b3e5aa3f284e58df8d5b827054f31697860 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Tue, 25 Aug 2026 21:23:46 +0200 Subject: [PATCH 24/24] docs(kb): close desktop UX verification plan --- ...026-08-24-pr-5540-kb-management-ux-plan.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/project/2026-08-24-pr-5540-kb-management-ux-plan.md b/project/2026-08-24-pr-5540-kb-management-ux-plan.md index 8fb28980bb..88d3c58a03 100644 --- a/project/2026-08-24-pr-5540-kb-management-ux-plan.md +++ b/project/2026-08-24-pr-5540-kb-management-ux-plan.md @@ -1,10 +1,10 @@ # Knowledge Base management UX audit and improvement roadmap -Status: draft PR published; implementation complete; final review and browser-proof blockers remain +Status: implementation complete; desktop browser proof and Sol final review passed; publication update pending Date: 2026-08-24 -Base snapshot: `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1` +Base snapshot: `1d57f4f11a65698b72916de7c6f14c66422f9293` Plan branch: `rs/kb-management-ux` @@ -34,10 +34,10 @@ The recommended direction is to make resources the primary workspace, present th - Empty and populated states. - Resource creation, resource search/filtering, inspection, ingestion status, and deletion affordances. - The surrounding chatbot-binding and knowledge-graph controls insofar as they affect hierarchy and cognitive load. -- English, desktop at 1440×900, and mobile at 390×844. -- A limited keyboard, focus, landmark, heading, and responsive-reflow check. +- English and German desktop behavior at 1440×900. +- A limited keyboard, focus, landmark, and heading check. -German, full WCAG conformance, ingestion correctness, graph quality, chatbot behavior, and production deployment are outside this audit. +Mobile layout, full WCAG conformance, ingestion correctness, graph quality, chatbot behavior, and production deployment are outside the accepted completion scope. The earlier mobile findings remain historical audit evidence for a separate shared Manage-shell follow-up. ### Evidence @@ -298,7 +298,7 @@ Do not modify `apps/frontend-manage/src/components/common/Header.tsx`, shared mo Per slice: inspect the diff, run the affected package checks and formatting, run the focused browser/test proof, obtain the applicable simplifier and slice review, update this plan’s Progress section, and create one conventional commit for the slice. After integration, run the final reviewer before presenting the package as complete. -Required checks include the affected package typechecks, repository formatting/lint checks, focused GraphQL regression coverage, the new KB Playwright spec in English and German, and browser screenshots at 1440×900, 390×844, and 320 CSS pixels. Keep the existing resource lifecycle tests and graph ledger tests unchanged unless a presentation-only selector or assertion requires a narrow update. The boundary owner must confirm any new policy error, media type, upload-ticket cleanup, or shared primitive change before implementation proceeds. +Required checks include the affected package typechecks, repository formatting/lint checks, focused GraphQL regression coverage, the new KB Playwright spec in English and German, and browser screenshots at 1440×900. Keep the existing resource lifecycle tests and graph ledger tests unchanged unless a presentation-only selector or assertion requires a narrow update. The boundary owner must confirm any new policy error, media type, upload-ticket cleanup, or shared primitive change before implementation proceeds. Pause before implementation or between slices for a material product/API decision, a need to change the current `BLOB`/`URL` contract, a shared Manage-shell or modal change, a graph lifecycle change, a destructive/external action, unavailable required credentials, or a verification blocker that remains after distinct safe approaches. The plan terminates after local checks, browser evidence, required reviews, documentation updates, a clean scoped diff, and stopping/verifying the exact implementation runtime unless explicitly kept running. Publishing, merging, deploying, and runtime/worktree deletion remain separate authorities. @@ -321,15 +321,16 @@ Pause before implementation or between slices for a material product/API decisio - [x] The latest `origin/v3` local-runtime improvement (`2619be5a2`) is selectively adapted without merging unrelated v3 changes: dependency fingerprinting, bounded stale Next.js cache repair, semantic app readiness, `dev:doctor`, and runtime guard tests are preserved alongside the KB/Azurite startup wiring. The exact linked workspace was re-reconciled successfully with `KB_GRAPH_BLOB_HOST_PORT=10004`; all five Next.js readiness contracts passed. Runtime checks pass for `test:dev-runtime`, `dev:doctor`, formatting, the KB package, and Playwright TypeScript. - [x] Direct browser verification against the retained workspace reaches the redesigned authenticated detail page, metadata table, and unified add-resource chooser; Website and Document are available and Video is disabled as “Coming soon”. Desktop and 390px screenshots are captured in `/private/tmp/kb-management-ux-add-desktop.png` and `/private/tmp/kb-management-ux-detail-mobile.png`. - [x] Sol final review of `42773ea45` returned `DONE_WITH_CONCERNS` for one low-severity README cache-policy wording mismatch; the wording now matches the unconditional `.next/dev` cleanup and bounded full-cache repair behavior. The same reviewer’s correction pass over `da6149e29` returned `DONE`; no code, security, architecture, or scope findings remain. -- [x] The user explicitly authorized normal publication. The exact final head `0f552deeed4fac8828db653bd173737e8da53600` is pushed to `rs/kb-management-ux`, and draft PR #5540 is read back against `feat/kb-graph-lifecycle` at #5424 head `77ab853f697b8ffdeb2f9956fd387deb2e6eccb1`; CI is pending on this head. -- [ ] S5 full browser proof remains pending: the KB journey passes creation, chooser focus handling, resource table, and inspector checks, then the document-level 390px/320px reflow assertion detects the known shared Manage-header overflow classified as X1 outside this package. +- [x] The reviewed implementation range is based on #5424 head `1d57f4f11a65698b72916de7c6f14c66422f9293` and ends at `9a0792a891e1850bb3b3b284eef4be12f6cbd77a`. Normal publication remains authorized; this plan-only closure update follows that reviewed head before the branch readback is refreshed. +- [x] S5 desktop browser proof passes through `util/run-host-e2e.sh`: the English/German KB journey covers creation, chooser focus handling, the pending document-upload dismissal lock, successful confirmation dismissal, refresh-failure dismissal, the resource table, and the inspector. Mobile and the known shared Manage-header overflow are explicitly outside the accepted completion scope. +- [x] Sol final review of the exact reviewed range confirmed both prior modal findings are resolved and found no correctness, security, architecture, maintainability, desktop UX, or stack-compatibility defects. Its only plan-status finding is closed by this update. ## Verification plan for implementation Each slice should be verified against the same seeded local fixture and a synthetic populated Knowledge Base. The repository currently has no KB-management Playwright spec, so the implementation must add focused coverage rather than treating screenshots as regression protection. - Desktop: catalog, empty detail, populated table, add-resource chooser, website form, document upload states, inspector, failed ingestion, and delete confirmation at 1440×900. -- Mobile: the same core states at 390×844 and a narrow reflow check at 320 CSS pixels, with no horizontal scroll and the resource table’s mobile fallback visible. +- Mobile: deferred to the separate shared Manage-shell follow-up; it is not a completion gate for this desktop-focused package. - Keyboard: open/close/focus return for every dialog, focus containment, table row selection, filters, load-more, primary row action, and confirmation cancellation. Screenshots alone do not prove these behaviors. - Assistive technology smoke check: one page-level H1, one main landmark, labeled table headers, row/action names, status announcements, and dialog name/description. - Localization: English and German strings remain in parity for the chooser, table headers, status labels, errors, and unavailable Video option.