From 208bd9c3724f912a3ab21d48b228675382af49e1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 7 Oct 2026 23:29:42 +0200 Subject: [PATCH] docs(openspec): archive pass, changes the code has delivered Archives beta-surface-alignment, store-plane-dashboard-sharing, wizard-dataset-card-load and keyboard-accessible-widget-repositioning (ticked from code: the grid half landed in nextcloud-vue CnDashboardGrid). --- .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/beta-alignment/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/grid-layout/spec.md | 0 .../tasks.md | 33 ++- .../design.md | 0 .../proposal.md | 0 .../specs/dashboard-store/spec.md | 2 +- .../tasks.md | 0 .../proposal.md | 0 .../specs/first-time-setup/spec.md | 0 .../tasks.md | 0 openspec/parity/capabilities.json | 2 +- openspec/specs/beta-alignment/spec.md | 47 +++ openspec/specs/dashboard-store/spec.md | 274 ++++++++++++++++++ openspec/specs/first-time-setup/spec.md | 54 ++++ openspec/specs/grid-layout/spec.md | 70 +++++ 20 files changed, 469 insertions(+), 13 deletions(-) rename openspec/changes/{beta-surface-alignment => archive/2026-10-07-beta-surface-alignment}/.openspec.yaml (100%) rename openspec/changes/{beta-surface-alignment => archive/2026-10-07-beta-surface-alignment}/proposal.md (100%) rename openspec/changes/{beta-surface-alignment => archive/2026-10-07-beta-surface-alignment}/specs/beta-alignment/spec.md (100%) rename openspec/changes/{beta-surface-alignment => archive/2026-10-07-beta-surface-alignment}/tasks.md (100%) rename openspec/changes/{keyboard-accessible-widget-repositioning => archive/2026-10-07-keyboard-accessible-widget-repositioning}/.openspec.yaml (100%) rename openspec/changes/{keyboard-accessible-widget-repositioning => archive/2026-10-07-keyboard-accessible-widget-repositioning}/proposal.md (100%) rename openspec/changes/{keyboard-accessible-widget-repositioning => archive/2026-10-07-keyboard-accessible-widget-repositioning}/specs/grid-layout/spec.md (100%) rename openspec/changes/{keyboard-accessible-widget-repositioning => archive/2026-10-07-keyboard-accessible-widget-repositioning}/tasks.md (59%) rename openspec/changes/{store-plane-dashboard-sharing => archive/2026-10-07-store-plane-dashboard-sharing}/design.md (100%) rename openspec/changes/{store-plane-dashboard-sharing => archive/2026-10-07-store-plane-dashboard-sharing}/proposal.md (100%) rename openspec/changes/{store-plane-dashboard-sharing => archive/2026-10-07-store-plane-dashboard-sharing}/specs/dashboard-store/spec.md (99%) rename openspec/changes/{store-plane-dashboard-sharing => archive/2026-10-07-store-plane-dashboard-sharing}/tasks.md (100%) rename openspec/changes/{wizard-dataset-card-load => archive/2026-10-07-wizard-dataset-card-load}/proposal.md (100%) rename openspec/changes/{wizard-dataset-card-load => archive/2026-10-07-wizard-dataset-card-load}/specs/first-time-setup/spec.md (100%) rename openspec/changes/{wizard-dataset-card-load => archive/2026-10-07-wizard-dataset-card-load}/tasks.md (100%) create mode 100644 openspec/specs/beta-alignment/spec.md create mode 100644 openspec/specs/dashboard-store/spec.md create mode 100644 openspec/specs/first-time-setup/spec.md diff --git a/openspec/changes/beta-surface-alignment/.openspec.yaml b/openspec/changes/archive/2026-10-07-beta-surface-alignment/.openspec.yaml similarity index 100% rename from openspec/changes/beta-surface-alignment/.openspec.yaml rename to openspec/changes/archive/2026-10-07-beta-surface-alignment/.openspec.yaml diff --git a/openspec/changes/beta-surface-alignment/proposal.md b/openspec/changes/archive/2026-10-07-beta-surface-alignment/proposal.md similarity index 100% rename from openspec/changes/beta-surface-alignment/proposal.md rename to openspec/changes/archive/2026-10-07-beta-surface-alignment/proposal.md diff --git a/openspec/changes/beta-surface-alignment/specs/beta-alignment/spec.md b/openspec/changes/archive/2026-10-07-beta-surface-alignment/specs/beta-alignment/spec.md similarity index 100% rename from openspec/changes/beta-surface-alignment/specs/beta-alignment/spec.md rename to openspec/changes/archive/2026-10-07-beta-surface-alignment/specs/beta-alignment/spec.md diff --git a/openspec/changes/beta-surface-alignment/tasks.md b/openspec/changes/archive/2026-10-07-beta-surface-alignment/tasks.md similarity index 100% rename from openspec/changes/beta-surface-alignment/tasks.md rename to openspec/changes/archive/2026-10-07-beta-surface-alignment/tasks.md diff --git a/openspec/changes/keyboard-accessible-widget-repositioning/.openspec.yaml b/openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/.openspec.yaml similarity index 100% rename from openspec/changes/keyboard-accessible-widget-repositioning/.openspec.yaml rename to openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/.openspec.yaml diff --git a/openspec/changes/keyboard-accessible-widget-repositioning/proposal.md b/openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/proposal.md similarity index 100% rename from openspec/changes/keyboard-accessible-widget-repositioning/proposal.md rename to openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/proposal.md diff --git a/openspec/changes/keyboard-accessible-widget-repositioning/specs/grid-layout/spec.md b/openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/specs/grid-layout/spec.md similarity index 100% rename from openspec/changes/keyboard-accessible-widget-repositioning/specs/grid-layout/spec.md rename to openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/specs/grid-layout/spec.md diff --git a/openspec/changes/keyboard-accessible-widget-repositioning/tasks.md b/openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/tasks.md similarity index 59% rename from openspec/changes/keyboard-accessible-widget-repositioning/tasks.md rename to openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/tasks.md index c32c15de8..feba36b4c 100644 --- a/openspec/changes/keyboard-accessible-widget-repositioning/tasks.md +++ b/openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning/tasks.md @@ -1,15 +1,26 @@ # Tasks — keyboard-accessible-widget-repositioning +> Archive pass 2026-10-07: every task ticked from code. `src/components/DashboardGrid.vue` was deleted when the grid moved to nextcloud-vue's `CnDashboardGrid`, so tasks 1 to 3 landed there (nextcloud-vue `development`): +> - Task 1: `src/components/CnDashboardGrid/CnDashboardGrid.vue:35-38` (`role="group"`, `:aria-label`, `:tabindex`). +> - Task 2: `CnDashboardGrid.vue:1055` (`.grid-stack-item:focus-visible`). +> - Task 3: `CnDashboardGrid.vue:633` (Enter and Space) and `:845-867` (`item-activate`, then a synthesised `contextmenu` that launchpad's `@contextmenu` handler at `src/views/Views.vue:290` opens the menu from). +> - Task 4: `src/components/Widgets/WidgetContextMenu.vue:26` (Move) and `:110` (`move` emit). +> - Task 5: `src/modals/WidgetMovePanel.vue` (in `src/modals/`, per the modal-isolation rule). +> - Task 6: `src/composables/useGridManager.js:248` (`nudgePlacement`). +> - Task 7: `src/views/Views.vue:420` and `:444-450`. +> - Task 8: `src/modals/__tests__/WidgetMovePanel.spec.js`. Task 9: `src/components/Widgets/__tests__/WidgetContextMenu.spec.js:59`. +> - Tasks 10 and 11: `tests/e2e/keyboard-widget-repositioning.spec.ts` (keyboard-only move, focus trap), plus nextcloud-vue `tests/a11y/CnDashboardGrid.a11y.spec.js` and `tests/components/CnDashboardGridKeyboard.spec.js`. + ## Grid item accessibility baseline -- [ ] Task 1: In `src/components/DashboardGrid.vue`, add `role="group"`, +- [x] Task 1: In `src/components/DashboardGrid.vue`, add `role="group"`, `:aria-label="getPlacementAriaLabel(placement)"` (new computed helper returning e.g. `Widget: {title}` or `Tile: {title}`), and `tabindex="0"` to the `.grid-stack-item` element (currently lines 9-20). -- [ ] Task 2: Add `:focus-visible` CSS to `.grid-stack-item` using NC +- [x] Task 2: Add `:focus-visible` CSS to `.grid-stack-item` using NC variables (`var(--color-primary-element)` outline), matching the existing visual language of the component's other interactive states. -- [ ] Task 3: Wire `keydown.enter` and `keydown.space` on the grid item (in +- [x] Task 3: Wire `keydown.enter` and `keydown.space` on the grid item (in edit mode only) to open the same context-menu / move-panel flow that right-click already triggers, calling the existing `onItemContextMenu` handler with a synthesized position (e.g. centered on the focused @@ -17,11 +28,11 @@ ## Move action -- [ ] Task 4: In `src/components/Widgets/WidgetContextMenu.vue`, add a new +- [x] Task 4: In `src/components/Widgets/WidgetContextMenu.vue`, add a new `role="menuitem"` button "Move" between the existing Edit (line 13) and Remove (line 29) buttons, with a new `onMove()` handler emitting a new `move` event (add `'move'` to the `emits` array at line 88) then `close`. -- [ ] Task 5: Create `src/components/Widgets/WidgetMovePanel.vue` — a +- [x] Task 5: Create `src/components/Widgets/WidgetMovePanel.vue` — a focus-trapped panel/modal (reuse `NcModal` or a lightweight popover matching `WidgetContextMenu`'s positioning approach) that: - Displays the widget's current `gridX/gridY/gridWidth/gridHeight`. @@ -33,29 +44,29 @@ `Escape` to cancel (emit `close` with no change). - Shows a live text readout of the pending position/size for screen readers (`aria-live="polite"`). -- [ ] Task 6: In `src/composables/useGridManager.js`, extract a pure +- [x] Task 6: In `src/composables/useGridManager.js`, extract a pure helper (e.g. `nudgePlacement(placement, direction, allPlacements)`) that computes the candidate new rect and reuses the same collision/pushing logic `placeNewWidget()` (lines 232+) already implements, so keyboard moves get the same collision-avoidance behaviour as drag moves. -- [ ] Task 7: Wire `DashboardGrid.vue`'s `move` event (from the context +- [x] Task 7: Wire `DashboardGrid.vue`'s `move` event (from the context menu) to open `WidgetMovePanel`, and on `save` call the existing placement-update path (the same one GridStack's `change` event already uses) with the panel's resulting rect. ## Tests -- [ ] Task 8: Add a Vitest test for `WidgetMovePanel.vue` asserting arrow +- [x] Task 8: Add a Vitest test for `WidgetMovePanel.vue` asserting arrow keys nudge the displayed position, `Shift+Arrow` resizes, `Enter` emits `save` with the expected rect, and `Escape` emits `close` with no `save`. -- [ ] Task 9: Add a Vitest test for `WidgetContextMenu.vue` asserting the +- [x] Task 9: Add a Vitest test for `WidgetContextMenu.vue` asserting the new Move button emits `move` then `close`. -- [ ] Task 10: Add a Playwright e2e test (per `feedback_playwright-ui-only-newman-api.md` +- [x] Task 10: Add a Playwright e2e test (per `feedback_playwright-ui-only-newman-api.md` — UI-driven, not API-direct) that: focuses a grid item via `Tab`, opens the move panel via `Enter`, nudges it right twice via arrow keys, confirms via `Enter`, and asserts the widget's rendered grid position changed by the expected offset without any pointer/mouse events being dispatched. -- [ ] Task 11: Add an axe-core (or equivalent) accessibility check to the +- [x] Task 11: Add an axe-core (or equivalent) accessibility check to the existing Playwright/Vitest suite asserting each grid item has a discoverable accessible name and that the move panel traps focus correctly (no focus leak to the canvas behind it while open). diff --git a/openspec/changes/store-plane-dashboard-sharing/design.md b/openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/design.md similarity index 100% rename from openspec/changes/store-plane-dashboard-sharing/design.md rename to openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/design.md diff --git a/openspec/changes/store-plane-dashboard-sharing/proposal.md b/openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/proposal.md similarity index 100% rename from openspec/changes/store-plane-dashboard-sharing/proposal.md rename to openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/proposal.md diff --git a/openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md b/openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/specs/dashboard-store/spec.md similarity index 99% rename from openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md rename to openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/specs/dashboard-store/spec.md index e8da25dc2..8f14239a3 100644 --- a/openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md +++ b/openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/specs/dashboard-store/spec.md @@ -27,7 +27,7 @@ provisions one instance. Faking the registry inside the suite would test the fake. `StoreServiceTest` covers the install path against the engine's real signatures. -## NEW Requirements +## ADDED Requirements ### Requirement: REQ-STORE-001 The store routes MUST exist wherever the store page is declared diff --git a/openspec/changes/store-plane-dashboard-sharing/tasks.md b/openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/tasks.md similarity index 100% rename from openspec/changes/store-plane-dashboard-sharing/tasks.md rename to openspec/changes/archive/2026-10-07-store-plane-dashboard-sharing/tasks.md diff --git a/openspec/changes/wizard-dataset-card-load/proposal.md b/openspec/changes/archive/2026-10-07-wizard-dataset-card-load/proposal.md similarity index 100% rename from openspec/changes/wizard-dataset-card-load/proposal.md rename to openspec/changes/archive/2026-10-07-wizard-dataset-card-load/proposal.md diff --git a/openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md b/openspec/changes/archive/2026-10-07-wizard-dataset-card-load/specs/first-time-setup/spec.md similarity index 100% rename from openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md rename to openspec/changes/archive/2026-10-07-wizard-dataset-card-load/specs/first-time-setup/spec.md diff --git a/openspec/changes/wizard-dataset-card-load/tasks.md b/openspec/changes/archive/2026-10-07-wizard-dataset-card-load/tasks.md similarity index 100% rename from openspec/changes/wizard-dataset-card-load/tasks.md rename to openspec/changes/archive/2026-10-07-wizard-dataset-card-load/tasks.md diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 5aeace919..ebd9e9fad 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -606,7 +606,7 @@ "state": "built", "evidence": "nextcloud-vue CnDashboardGrid.vue:35-47 (main 0f1c0ffbb) makes every grid item in edit mode a focusable named group (role group, aria-label, tabindex 0 while keyboardRepositioning, default true) and turns Enter or Space into the contextmenu event a right-click sends; src/views/Views.vue:290 catches it on the placement and opens WidgetContextMenu; src/components/Widgets/WidgetContextMenu.vue:22-27 offers Move, which src/views/Views.vue:420 and :444 route to src/modals/WidgetMovePanel.vue; WidgetMovePanel.vue:286-310 moves with the arrow keys, resizes with Shift and the arrow keys, saves on Enter and cancels on Escape, through nudgePlacement (src/composables/useGridManager.js:248), the collision model the drag path uses; Views.vue:1964 handleMoveSave persists it. Tests: src/modals/__tests__/WidgetMovePanel.spec.js, tests/e2e/keyboard-widget-repositioning.spec.ts (drives the page with the keyboard only, checks the position after reload)", "owner": "ConductionNL/launchpad", - "change": "openspec/changes/keyboard-accessible-widget-repositioning", + "change": "openspec/changes/archive/2026-10-07-keyboard-accessible-widget-repositioning", "readOn": "2026-10-07" }, "reachedOn": "Workspace page, widget context menu, Move", diff --git a/openspec/specs/beta-alignment/spec.md b/openspec/specs/beta-alignment/spec.md new file mode 100644 index 000000000..e7c905900 --- /dev/null +++ b/openspec/specs/beta-alignment/spec.md @@ -0,0 +1,47 @@ +# beta-alignment Specification + +## Purpose +LaunchPad is described in four places: the app metadata in `appinfo/info.xml`, the pages in `src/manifest.json`, the product page on conduction.nl and the documentation. This capability keeps those four in step: they use the same feature vocabulary, the licence tag matches the licence text, and a translated product page lives at the same relative path as the page it translates. + +## Requirements + +### Requirement: The four public surfaces SHALL agree on feature vocabulary +`appinfo/info.xml`, the shipped feature set in `src/manifest.json` / `lib/`, +the product page (`conduction-website/src/pages/apps/launchpad.mdx` + its +Dutch translation), and `docs/` SHALL describe the same canonical feature +list using the same feature names, and SHALL NOT assert a capability that +is not present in `lib/` or `src/` at HEAD. + +#### Scenario: A feature bullet on the product page names a real capability +- **WHEN** the product page or docs describe a LaunchPad capability +- **THEN** that capability SHALL be traceable to a concrete file in `lib/` + or `src/` (a controller, service, widget renderer, or registry entry) + +#### Scenario: info.xml does not declare an install-time dependency the architecture forbids +- **WHEN** `docs/architecture.md` documents a "MUST NOT" install-time + dependency rule for an app (e.g. OpenRegister) +- **THEN** `appinfo/info.xml`'s `` block SHALL NOT list that + app + +### Requirement: The license tag SHALL match the declared license text +`appinfo/info.xml`'s `` element and its top-of-file SPDX header +SHALL match the license asserted in both `` CDATA blocks +("Free and open source under the EUPL-1.2 license") and every PHP file's +`@license` docblock tag. + +#### Scenario: licence tag matches description text +- **WHEN** `info.xml`'s description states a license +- **THEN** the `` element SHALL declare the same license + +### Requirement: A translated product page SHALL live at the same relative path as its source page +Docusaurus i18n resolves a translated page by matching the source page's +file path under the locale's content directory. A translation SHALL NOT be +left at a stale pre-rename filename after the English source page is +renamed. + +#### Scenario: English product page is renamed +- **WHEN** `conduction-website/src/pages/apps/.mdx` is renamed to + `.mdx` +- **THEN** every locale's translation under + `i18n//docusaurus-plugin-content-pages/apps/` SHALL be renamed to + the same `.mdx`, and no stale `.mdx` file SHALL remain diff --git a/openspec/specs/dashboard-store/spec.md b/openspec/specs/dashboard-store/spec.md new file mode 100644 index 000000000..cdc359448 --- /dev/null +++ b/openspec/specs/dashboard-store/spec.md @@ -0,0 +1,274 @@ +# dashboard-store Specification + +## Purpose +An administrator can connect LaunchPad to a registry, another OpenRegister instance that publishes dashboard templates, and install a template from the store page as a new dashboard. OpenRegister's store plane owns discovery (the address guard, the token transport and the outcome it reports); LaunchPad owns the install, which goes through the existing dashboard importer and always creates a dashboard, never replaces one. The registry token is write-only, and the store degrades to a not-configured state when OpenRegister is absent. + +## Requirements + +### Requirement: REQ-STORE-001 The store routes MUST exist wherever the store page is declared + +LaunchPad's manifest declares a `type: "store"` page, which renders `CnStorePage`, +which calls `GET /apps/launchpad/api/store/items` on mount and +`POST /apps/launchpad/api/store/items/{slug}/install` on an install click. +`appinfo/routes.php` SHALL declare both, and the `{slug}` route SHALL constrain +the slug to `[a-z0-9][a-z0-9-]*[a-z0-9]` so a malformed slug fails at the router +rather than reaching the registry URL. + +#### Scenario: The declared page can reach its endpoint + +- **GIVEN** `src/manifest.json` declares a page of type `store` +- **WHEN** the route table is read +- **THEN** it MUST contain `store#search` at `/api/store/items` +- **AND** it MUST contain `store#install` at `/api/store/items/{slug}/install` + +@e2e exclude route-table shape: asserted by reading appinfo/routes.php in tests/Unit/Support/StoreWiringTest.php; the browser consequence is the next scenario + +#### Scenario: An unconfigured store shows the not-configured state + +- **GIVEN** no `registry_url` is configured +- **WHEN** an administrator opens the Store page +- **THEN** `GET /api/store/items` MUST answer 200 with outcome `not_configured` +- **AND** the page MUST show its not-configured note +- **AND** it MUST NOT show the unreachable note or an empty result grid + +--- + +### Requirement: REQ-STORE-002 An absent OpenRegister MUST degrade, never fatal + +`GenericStoreService` is resolved optionally, exactly as LaunchPad already +resolves the AppHost observability collaborators. When OpenRegister is disabled or +absent the collaborator is null. `search()` MUST then return outcome +`not_configured` with an empty card list, and `install()` MUST refuse, and neither +MUST touch an `OCA\OpenRegister\…` symbol. + +An engine that is present but unconfigured behaves the same way, because +`GenericStoreService::isConfigured()` reports false for an empty `registry_url` +and makes no network call. + +#### Scenario: No OpenRegister yields not_configured + +- **GIVEN** the store service holds a null discovery client +- **WHEN** `search()` is called +- **THEN** the outcome MUST be `not_configured` and the card list MUST be empty + +@e2e exclude requires an instance WITHOUT OpenRegister, and CI installs it; asserted in tests/Unit/Service/StoreServiceTest.php + +#### Scenario: No OpenRegister refuses an install rather than half-running one + +- **GIVEN** the store service holds a null discovery client +- **WHEN** `install()` is called for any slug +- **THEN** it MUST report failure and MUST NOT call `ImportService` + +@e2e exclude requires an instance WITHOUT OpenRegister, and CI installs it; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-003 The engine's outcome MUST reach the caller unchanged + +The plane distinguishes `store_unreachable` from `store_invalid_response` so a +misconfigured registry does not read as an offline one. LaunchPad SHALL pass the +outcome through verbatim and MUST NOT collapse the two, and MUST NOT substitute +its own. Upstream error detail MUST NOT reach the caller; the engine already keeps +it server-side. + +#### Scenario: An invalid response is not reported as unreachable + +- **GIVEN** the engine returns outcome `store_invalid_response` +- **WHEN** the service returns +- **THEN** the outcome MUST be `store_invalid_response` + +@e2e exclude needs a registry that answers malformed JSON, and CI has no registry; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-004 Installing MUST require an administrator + +An install writes dashboards into the instance from a third-party server. The +install endpoint SHALL carry `#[AuthorizedAdminSetting(LaunchPadAdmin::class)]`, +the posture LaunchPad already uses for its other administrative endpoints. + +Search SHALL carry `#[NoAdminRequired]`, because browsing a registry addresses no +object of this instance. It forwards a query to an external registry and returns +normalised cards, so there is no local identifier to guess and no IDOR to create. + +#### Scenario: Every store route declares a posture + +- **GIVEN** the store controller +- **WHEN** its public methods are read +- **THEN** `search` MUST carry `#[NoAdminRequired]` +- **AND** `install` MUST carry `#[AuthorizedAdminSetting]` + +@e2e exclude attribute presence: asserted by reflection in tests/Unit/Controller/StoreControllerTest.php; the two scenarios below prove the attributes hold at runtime + +#### Scenario: A non-admin is refused the registry config + +- **GIVEN** a signed-in account that is not an administrator, holding a valid request token +- **WHEN** it reads or writes `/api/store/config` +- **THEN** both requests MUST answer 403 +- **AND** the stored registry MUST be unchanged afterwards + +#### Scenario: A non-admin is refused an install + +- **GIVEN** the same account +- **WHEN** it posts to `/api/store/items/{slug}/install` +- **THEN** the request MUST answer 403 + +--- + +### Requirement: REQ-STORE-005 An install MUST reuse ImportService, not reimplement it + +The resolved payload SHALL be materialised into a `launchpad-export-v1` ZIP in a +temporary directory and handed to `ImportService::import()`. LaunchPad MUST NOT +gain a second code path that turns a dashboard payload into rows. + +The materialised archive MUST carry a `manifest.json` with `schemaVersion` equal +to the importer's supported version and a `scope`, and one +`dashboards/{uuid}.json` entry per dashboard, because that is what +`validateZipStructure()` requires and what `ExportService` writes. + +The temporary archive MUST be removed whether the import succeeds or throws. + +#### Scenario: The payload reaches the existing importer + +- **GIVEN** a resolved item carrying one dashboard payload +- **WHEN** it is installed +- **THEN** `ImportService::import()` MUST be called exactly once +- **AND** the path it receives MUST be an existing ZIP containing `manifest.json` + +@e2e exclude needs a second OpenRegister publishing a template, and CI has one instance; asserted in tests/Unit/Service/StoreServiceTest.php + +#### Scenario: An install with no registry is refused with a reason + +- **GIVEN** no `registry_url` is configured +- **WHEN** an administrator posts an install for any slug +- **THEN** the request MUST answer 400 with `success` false and a message +- **AND** it MUST NOT answer 500 + +#### Scenario: A payload with no dashboards is refused before any import + +- **GIVEN** a resolved item whose dashboards list is empty or absent +- **WHEN** it is installed +- **THEN** the install MUST fail and `ImportService::import()` MUST NOT be called + +@e2e exclude needs a registry serving a malformed item, and CI has no registry; asserted in tests/Unit/Service/StoreServiceTest.php + +#### Scenario: The temporary archive does not survive a failing import + +- **GIVEN** `ImportService::import()` throws +- **WHEN** the install returns +- **THEN** no temporary archive from that install MUST remain on disk + +@e2e exclude a temp-directory effect with no browser surface; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-006 A store install MUST create dashboards, never replace them + +The install SHALL call `ImportService::import()` with `preserveUuids` false. A +template published by a foreign instance carries that instance's UUIDs, and one of +them can collide with a live local dashboard. Re-mapping makes the install +additive. + +This mirrors the plane's own rule that an install creates rather than replaces, +reached by a different route: the identity that would do the damage is the +dashboard UUID rather than an object `uuid`. + +#### Scenario: A colliding UUID still creates + +- **GIVEN** a resolved item whose dashboard UUID matches a local dashboard +- **WHEN** it is installed +- **THEN** `import()` MUST be called with `preserveUuids` false + +@e2e exclude needs a second OpenRegister publishing a template, and CI has one instance; asserted in tests/Unit/Service/StoreServiceTest.php + +--- + +### Requirement: REQ-STORE-007 The registry token MUST NOT be readable back + +`registry_url`, `registry_token` and `registry_register` live in `IAppConfig` +under `launchpad`, because that is where `GenericStoreService` reads them. +LaunchPad's own admin settings write to a different store, so the change adds an +admin-gated endpoint for these three keys. + +The read endpoint SHALL return the URL and the register, and for the token SHALL +return only whether one is set. The token value MUST NOT appear in any response. + +#### Scenario: Reading the config never returns the token + +- **GIVEN** `registry_token` holds a value +- **WHEN** the config is read +- **THEN** the response MUST report that a token is set +- **AND** the response MUST NOT contain the token value + +#### Scenario: An empty token submission clears rather than blanks around + +- **GIVEN** a stored token +- **WHEN** the config is written with an empty token +- **THEN** the stored token MUST be cleared + +@e2e exclude the clear-versus-keep distinction is a request-payload property the browser cannot see; asserted in tests/Unit/Service/StoreServiceTest.php and src/components/admin/__tests__/DashboardRegistrySettings.spec.js + +--- + +### Requirement: REQ-STORE-008 The manifest MUST NOT declare a store block + +`src/manifest.json` MUST NOT carry a `store` key. That block exists to configure +OpenRegister's `GenericStoreController`, which LaunchPad does not use, so a block +here would declare a schema and card fields that nothing reads while +`StoreService` declares the ones that are used. + +A non-empty `types` list is the specific harm, because it selects the engine's +federated configuration path. The block on `development` declared +`openregister.configset` and `openregister.flows`, which trade registers, schemas +and flows. Had the routes existed, the store page would have offered +configuration sets to somebody looking for a dashboard. + +#### Scenario: No store block is declared + +- **GIVEN** `src/manifest.json` +- **WHEN** it is decoded +- **THEN** it MUST NOT contain a `store` key + +@e2e exclude a source-file property; asserted in tests/Unit/Support/StoreWiringTest.php + +#### Scenario: The store page still declares itself + +- **GIVEN** the same manifest +- **WHEN** its pages are read +- **THEN** exactly one page MUST carry `type` of `store` + +@e2e exclude a source-file property; asserted in tests/Unit/Support/StoreWiringTest.php + +--- + +### Requirement: REQ-STORE-009 An administrator MUST be able to connect a registry without a shell + +The three registry keys live in `IAppConfig`, which LaunchPad's own admin +settings do not write. So Beheer ▸ Sharing SHALL carry a form for +`registry_url`, `registry_register` and the token, backed by +`/api/store/config`. + +The token field SHALL be write-only. It MUST start empty on every load, and +the page MUST say whether a token is set without showing it. Saving with an +empty token field MUST leave the stored token alone; removing it is a +separate action. + +#### Scenario: An administrator saves a registry and reads it back + +- **GIVEN** an administrator on Beheer ▸ Sharing +- **WHEN** they enter a registry URL, a register and a token, and save +- **THEN** after a reload the URL and register fields MUST show what they entered +- **AND** the page MUST say a token is set + +#### Scenario: The token is never shown back + +- **GIVEN** a stored token +- **WHEN** the form loads +- **THEN** the token field MUST be empty +- **AND** neither the page nor the config response MUST contain the token + +#### Scenario: The saved registry is the one the store reads + +- **GIVEN** an administrator saved a registry URL whose host does not resolve +- **WHEN** they open the Store page +- **THEN** the page MUST show the unreachable note rather than the not-configured one diff --git a/openspec/specs/first-time-setup/spec.md b/openspec/specs/first-time-setup/spec.md new file mode 100644 index 000000000..c73d3bf7c --- /dev/null +++ b/openspec/specs/first-time-setup/spec.md @@ -0,0 +1,54 @@ +# first-time-setup Specification + +## Purpose +The first-time setup wizard gets a new LaunchPad installation to a working state. The operator picks an example dataset on a card and loads it from that card, and the setup status reports every step the manifest declares. This capability defines those steps and what the server accepts and refuses while running them. + +## Requirements + +### Requirement: Each example data card loads itself + +The `demo-data` setup step MUST be a cards choice step with `loadAction: load-demo-data`. The setup wizard MUST NOT carry a separate run-action step that loads the picked dataset. + +#### Scenario: The operator loads a dataset from its card + +- GIVEN the setup wizard shows the example data cards +- WHEN the operator presses Load on a card +- THEN the wizard posts `{ "dataset": }` to `/api/setup/action/load-demo-data` +- AND the server loads that dataset +- AND the server records the dataset as the pick only after the load succeeds +- @e2e exclude the card and its spinner are CnSetupWizard UI, tested in nextcloud-vue; the posted body is covered by tests/Unit/Controller/SetupControllerTest.php + +#### Scenario: An unknown dataset is refused + +- GIVEN a dataset id that no card offers +- WHEN it is posted to `/api/setup/action/load-demo-data` +- THEN the server answers 400 with `success: false` +- AND nothing is loaded or stored +- @e2e tests/e2e/spec-coverage/demo-data-setup-step.spec.ts + +#### Scenario: A call without a body keeps working + +- GIVEN a dataset was stored through `/api/setup/config` +- WHEN `/api/setup/action/load-demo-data` is called without a body +- THEN the stored dataset is loaded +- @e2e tests/e2e/spec-coverage/demo-data-setup-step.spec.ts + +#### Scenario: A failed load leaves the step open + +- GIVEN the load of the posted dataset fails +- WHEN the server answers +- THEN the answer carries `success: false` +- AND no pick or decision is stored +- @e2e exclude needs a load that fails on a live instance; covered by tests/Unit/Controller/SetupControllerTest.php + +### Requirement: Setup status reports every manifest step + +`GET /api/setup/status` MUST report a `done` state for every step id in `manifest.setup.steps`. + +#### Scenario: The status ids match the manifest + +- GIVEN the LaunchPad manifest +- WHEN an administrator reads `/api/setup/status` +- THEN `steps` holds an entry for every manifest step id +- AND the retired load step is not reported +- @e2e tests/e2e/spec-coverage/demo-data-setup-step.spec.ts diff --git a/openspec/specs/grid-layout/spec.md b/openspec/specs/grid-layout/spec.md index 031c5bd2f..b6ec3eede 100644 --- a/openspec/specs/grid-layout/spec.md +++ b/openspec/specs/grid-layout/spec.md @@ -477,6 +477,76 @@ All "add widget" code paths (toolbar dropdown, keyboard shortcut, drag-from-pick - **THEN** it MUST call `placeNewWidget(spec)` exported from `useGridManager.js` - **AND** MUST NOT call `grid.addWidget(...)` directly +### Requirement: REQ-GRID-KBD-001 Grid items expose an accessible role and name + +Every rendered grid item MUST carry `role="group"`, a computed +`aria-label` describing the widget (e.g. "Widget: {title}"), and +`tabindex="0"` so it is reachable via sequential keyboard navigation +(`Tab`/`Shift+Tab`) and announced by assistive technology as a distinct, +named unit. + +#### Scenario: Grid item is keyboard-focusable + +- **GIVEN** a dashboard with 3 widget placements in edit mode +- **WHEN** a keyboard user presses `Tab` repeatedly from the canvas +- **THEN** focus MUST land on each grid item in turn +- **AND** each focused item MUST have a visible focus indicator + +#### Scenario: Screen reader announces the widget + +- **GIVEN** a grid item wrapping a widget titled "Team Announcements" +- **WHEN** a screen reader user focuses the item +- **THEN** it MUST announce a group with the accessible name "Widget: + Team Announcements" (or the tile-equivalent label for tile placements) + +### Requirement: REQ-GRID-KBD-002 Keyboard-operable move and resize + +Any user MUST be able to reposition or resize a focused widget placement +using only the keyboard, producing the same `gridX`/`gridY`/`gridWidth`/ +`gridHeight` outcome a mouse drag would produce, including respecting the +existing minimum size (`gs-min-w`/`gs-min-h` = 2) and existing collision/ +push behaviour. + +#### Scenario: Opening the move panel via keyboard + +- **GIVEN** a grid item has keyboard focus +- **WHEN** the user presses `Enter` or `Space` +- **THEN** the same panel/menu reachable via right-click MUST open +- **AND** a "Move" action MUST be present in that panel/menu + +#### Scenario: Nudging position with arrow keys + +- **GIVEN** the move panel is open for a widget at `gridX=2, gridY=3` +- **WHEN** the user presses `ArrowRight` +- **THEN** the pending position MUST become `gridX=3, gridY=3` +- **AND** the change MUST NOT be persisted until the user confirms + +#### Scenario: Resizing with Shift+arrow keys + +- **GIVEN** the move panel is open for a widget at `gridWidth=3, + gridHeight=2` +- **WHEN** the user presses `Shift+ArrowRight` +- **THEN** the pending size MUST become `gridWidth=4, gridHeight=2` +- **AND** the size MUST NOT shrink below the existing 2-cell minimum in + either dimension + +#### Scenario: Confirming and cancelling + +- **GIVEN** the move panel is open with a pending position/size change +- **WHEN** the user presses `Enter` +- **THEN** the placement MUST be updated via the same persistence path + drag-and-drop already uses +- **WHEN** instead the user presses `Escape` +- **THEN** no change MUST be persisted and the panel MUST close + +#### Scenario: Collisions are resolved the same way as drag + +- **GIVEN** a keyboard-driven move would overlap an existing placement +- **WHEN** the user confirms the move +- **THEN** the system MUST apply the same collision/push resolution + `placeNewWidget()` already applies for drag-and-drop moves — no widget + MUST silently overlap another after confirmation + ## Non-Functional Requirements - **Performance**: Grid initialization MUST complete within 500ms for dashboards with up to 30 widget placements. Drag and resize interactions MUST maintain 60fps with no visible lag.