From 99372c696477d81275849d8321a714b18b6a7061 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 25 May 2026 23:19:42 +0200 Subject: [PATCH 1/4] retrofit: annotate 18 backend methods + reverse-spec settings-and-observability (5 REQs) --- lib/Controller/HealthController.php | 2 + lib/Controller/MetricsController.php | 2 + lib/Controller/SettingsController.php | 6 + lib/Listener/DeepLinkRegistrationListener.php | 2 + lib/Repair/InitializeSettings.php | 2 + lib/Service/ApplicationVersionService.php | 2 + lib/Service/ExportJobService.php | 2 + lib/Service/ExportService.php | 6 + lib/Service/PlaceholderResolver.php | 4 + lib/Service/SettingsService.php | 8 + .../design.md | 17 ++ .../proposal.md | 24 +++ .../specs/settings-and-observability/spec.md | 147 ++++++++++++++++++ .../tasks.md | 7 + .../specs/settings-and-observability/spec.md | 147 ++++++++++++++++++ 15 files changed, 378 insertions(+) create mode 100644 openspec/changes/retrofit-2026-05-25-settings-and-observability/design.md create mode 100644 openspec/changes/retrofit-2026-05-25-settings-and-observability/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-25-settings-and-observability/specs/settings-and-observability/spec.md create mode 100644 openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md create mode 100644 openspec/specs/settings-and-observability/spec.md diff --git a/lib/Controller/HealthController.php b/lib/Controller/HealthController.php index a1ea8fb4..78be0565 100644 --- a/lib/Controller/HealthController.php +++ b/lib/Controller/HealthController.php @@ -61,6 +61,8 @@ public function __construct( * @NoCSRFRequired * * @return JSONResponse + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-5 */ #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Controller/MetricsController.php b/lib/Controller/MetricsController.php index 42c8beae..51241c3b 100644 --- a/lib/Controller/MetricsController.php +++ b/lib/Controller/MetricsController.php @@ -62,6 +62,8 @@ public function __construct( * @NoCSRFRequired * * @return JSONResponse + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-5 */ #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 6503d26f..6129138c 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -63,6 +63,8 @@ public function __construct( * @NoCSRFRequired * * @return JSONResponse + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-1 */ #[NoAdminRequired] #[NoCSRFRequired] @@ -84,6 +86,8 @@ public function index(): JSONResponse * @NoCSRFRequired * * @return JSONResponse + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-2 */ #[NoAdminRequired] #[NoCSRFRequired] @@ -114,6 +118,8 @@ public function create(): JSONResponse * @NoCSRFRequired * * @return JSONResponse + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-3 */ #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Listener/DeepLinkRegistrationListener.php b/lib/Listener/DeepLinkRegistrationListener.php index b5fe4a64..3ae1931b 100644 --- a/lib/Listener/DeepLinkRegistrationListener.php +++ b/lib/Listener/DeepLinkRegistrationListener.php @@ -44,6 +44,8 @@ class DeepLinkRegistrationListener implements IEventListener * @param Event $event The event to handle * * @return void + * + * @spec openspec/changes/archive/retrofit-2026-05-24-deep-link-registration/tasks.md#task-1 */ public function handle(Event $event): void { diff --git a/lib/Repair/InitializeSettings.php b/lib/Repair/InitializeSettings.php index 75cb9b36..e99fae1a 100644 --- a/lib/Repair/InitializeSettings.php +++ b/lib/Repair/InitializeSettings.php @@ -61,6 +61,8 @@ public function getName(): string * @param IOutput $output The output interface for progress reporting * * @return void + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-4 */ public function run(IOutput $output): void { diff --git a/lib/Service/ApplicationVersionService.php b/lib/Service/ApplicationVersionService.php index 1be386a6..2b1e348d 100644 --- a/lib/Service/ApplicationVersionService.php +++ b/lib/Service/ApplicationVersionService.php @@ -746,6 +746,8 @@ private function canonicaliseValue(mixed $value): mixed * @return string The register's slug, or empty string when unavailable * * @internal Exposed only to internal callers; not part of the public API. + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-23 */ public function describeRegister(?Register $register): string { diff --git a/lib/Service/ExportJobService.php b/lib/Service/ExportJobService.php index df14efad..f0ac554c 100644 --- a/lib/Service/ExportJobService.php +++ b/lib/Service/ExportJobService.php @@ -374,6 +374,8 @@ public function credentialKey(string $jobUuid): string * Generate a UUIDv4. * * @return string UUIDv4. + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-37 */ public function uuid4(): string { diff --git a/lib/Service/ExportService.php b/lib/Service/ExportService.php index fabcfef8..5d01fda1 100644 --- a/lib/Service/ExportService.php +++ b/lib/Service/ExportService.php @@ -266,6 +266,8 @@ public function resolvePlaceholders(string $rootDir, array $context): void * @param string $path File path. * * @return bool True when the file should be copied as-is. + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-40 */ public function isBinary(string $path): bool { @@ -329,6 +331,8 @@ public function copyTemplate(string $source, string $dest): void * @param string $jobUuid ExportJob UUID. * * @return string Local path to the scratch dir. + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-35 */ public function prepareScratchDir(string $jobUuid): string { @@ -399,6 +403,8 @@ public function getOrCreateAppDataDir(string $name): string * @param string $dir Directory to remove. * * @return void + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-35 */ public function rrmdir(string $dir): void { diff --git a/lib/Service/PlaceholderResolver.php b/lib/Service/PlaceholderResolver.php index b457e45c..ac6bc09e 100644 --- a/lib/Service/PlaceholderResolver.php +++ b/lib/Service/PlaceholderResolver.php @@ -98,6 +98,8 @@ public function resolve(string $content, array $map): string * @param string $value Source value. * * @return string Slug. + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-40 */ public function slug(string $value): string { @@ -125,6 +127,8 @@ public function slug(string $value): string * @param string $value Source value. * * @return string PascalCase'd identifier. + * + * @spec openspec/changes/archive/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-40 */ public function pascalCase(string $value): string { diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 2c62bcd5..22474928 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -86,6 +86,8 @@ public function isOpenRegisterAvailable(): bool * fields (openregisters, isAdmin) consumed by the frontend. * * @return array + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-1 */ public function getSettings(): array { @@ -112,6 +114,8 @@ public function getSettings(): array * @param array $data The data to update * * @return array The updated settings + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-2 */ public function updateSettings(array $data): array { @@ -132,6 +136,8 @@ public function updateSettings(array $data): array * reloadConfiguration() to force a re-import. * * @return array Result with success flag, message, and version. + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-3 */ public function loadConfiguration(): array { @@ -145,6 +151,8 @@ public function loadConfiguration(): array * Used by the InitializeSettings repair step and the admin "Reload" action. * * @return array Result with success flag, message, and version. + * + * @spec openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md#task-3 */ public function reloadConfiguration(): array { diff --git a/openspec/changes/retrofit-2026-05-25-settings-and-observability/design.md b/openspec/changes/retrofit-2026-05-25-settings-and-observability/design.md new file mode 100644 index 00000000..41575285 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-settings-and-observability/design.md @@ -0,0 +1,17 @@ +# Design — settings-and-observability (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new +implementation work. The code already exists and ships in OpenBuilt; +this change records the observed behaviour of the settings surface and +the liveness/metrics probes as numbered REQs so gate-16 spec-coverage +can trace each method to a requirement. + +## Observed shape +- `SettingsService` owns the read/update/import logic; the controller + is a thin auth-gated HTTP wrapper over it. +- `InitializeSettings` repair step force-reloads the bundled register + config at install/upgrade. +- `HealthController` / `MetricsController` are CSRF/admin-exempt but + still require an authenticated session. + +No behaviour is changed by this retrofit. diff --git a/openspec/changes/retrofit-2026-05-25-settings-and-observability/proposal.md b/openspec/changes/retrofit-2026-05-25-settings-and-observability/proposal.md new file mode 100644 index 00000000..2ea89ecd --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-settings-and-observability/proposal.md @@ -0,0 +1,24 @@ +# Retrofit — settings-and-observability + +Describes observed behaviour of 10 methods under `settings-and-observability` +as 5 new REQs. Code already exists — this change retroactively specifies it. + +## Affected code units +- lib/Service/SettingsService.php::getSettings() +- lib/Service/SettingsService.php::updateSettings() +- lib/Service/SettingsService.php::loadConfiguration() +- lib/Service/SettingsService.php::reloadConfiguration() +- lib/Controller/SettingsController.php::index() +- lib/Controller/SettingsController.php::create() +- lib/Controller/SettingsController.php::load() +- lib/Repair/InitializeSettings.php::run() +- lib/Controller/HealthController.php::index() +- lib/Controller/MetricsController.php::index() + +## Approach +- For each method: describe observed inputs, outputs, pre/postconditions, failure modes +- Draft REQs that match behaviour (not aspirational) +- Notes section surfaces the empty metrics payload and the authenticated-probe quirk + +Source: openspec/coverage-report (gate-16 spec-coverage) generated 2026-05-25. +See [retrofit playbook](../../../.github/docs/claude/retrofit.md). diff --git a/openspec/changes/retrofit-2026-05-25-settings-and-observability/specs/settings-and-observability/spec.md b/openspec/changes/retrofit-2026-05-25-settings-and-observability/specs/settings-and-observability/spec.md new file mode 100644 index 00000000..6bc4741b --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-settings-and-observability/specs/settings-and-observability/spec.md @@ -0,0 +1,147 @@ +--- +retrofit: true +--- + +# settings-and-observability Specification + +## Purpose + +OpenBuilt exposes a small administrative surface that lets the frontend +read and write app-level configuration (the OpenRegister register key), +discover whether OpenRegister is installed, and learn whether the +current user is an admin. The same surface re-imports the bundled +`openbuilt_register.json` configuration into OpenRegister — both +lazily (idempotent first-boot import) and forcibly (admin "Reload" +action / repair step). Two lightweight probe endpoints expose liveness +and a placeholder metrics payload for container orchestrators and +load balancers. + +This capability is observed behaviour of the `SettingsService`, +`SettingsController`, `InitializeSettings` repair step, and the +`HealthController` / `MetricsController` probe endpoints. + +## Requirements + +### REQ-OBS-001: Settings read returns config plus environment metadata + +`SettingsService::getSettings()` SHALL return a flat array containing +every key in `CONFIG_KEYS` (currently `register`), read from +`IAppConfig` for the `openbuilt` app with an empty-string default, +merged with two computed metadata fields: `openregisters` (true when +the `openregister` app is installed, via +`isOpenRegisterAvailable()`) and `isAdmin` (true when a user is signed +in and is a member of the admin group). `SettingsController::index()` +SHALL return this array as a `JSONResponse`, rejecting unauthenticated +callers with HTTP 401. + +#### Scenario: Authenticated read + +- **WHEN** a signed-in user calls the settings index endpoint +- **THEN** the response contains the `register` config value plus + `openregisters` and `isAdmin` booleans +- **AND** the HTTP status is 200 + +#### Scenario: Unauthenticated read + +- **WHEN** an unauthenticated request hits the settings index endpoint +- **THEN** the response is `{"error":"Unauthenticated."}` with HTTP 401 + +### REQ-OBS-002: Settings update persists only whitelisted keys + +`SettingsService::updateSettings(array $data)` SHALL iterate over +`CONFIG_KEYS` and persist via `IAppConfig::setValueString` only those +keys present in the supplied data — any other input keys are ignored. +It SHALL return the freshly re-read settings array (REQ-OBS-001 shape). +`SettingsController::create()` SHALL read the request params, call +`updateSettings`, and return `{"success":true,"config":}`, +rejecting unauthenticated callers with HTTP 401. + +#### Scenario: Persist a known key + +- **WHEN** an authenticated caller POSTs `{"register":"openbuilt"}` +- **THEN** the `register` app-config value is set to `openbuilt` +- **AND** the response echoes `success:true` and the updated config + +#### Scenario: Unknown key ignored + +- **WHEN** the payload contains a key not in `CONFIG_KEYS` +- **THEN** that key is not written to app config + +### REQ-OBS-003: Configuration import is idempotent and force-reloadable + +`SettingsService::loadConfiguration()` SHALL import the bundled +`lib/Settings/openbuilt_register.json` into OpenRegister via +`ConfigurationService::importFromApp` with `force: false`, relying on +OR to short-circuit an already-imported configuration. +`reloadConfiguration()` SHALL perform the same import with +`force: true`. The shared private `doLoadConfiguration(bool $force)` +SHALL return `{success:false,message}` when OpenRegister is absent, the +config file is missing, unreadable, or unparseable, and +`{success:true,message,version}` on a non-empty import result — the +version being taken from the import result or falling back to the +config file's `info.version`. Any thrown error SHALL be logged and +returned as `{success:false,message:}`. +`SettingsController::load()` SHALL invoke `reloadConfiguration()` and +return its result, rejecting unauthenticated callers with HTTP 401. + +#### Scenario: First import succeeds + +- **WHEN** `loadConfiguration()` runs with OpenRegister installed and a + valid config file +- **THEN** `importFromApp` is called with `force:false` +- **AND** a `{success:true, version}` result is returned + +#### Scenario: OpenRegister absent + +- **WHEN** the import runs and OpenRegister is not installed +- **THEN** the result is `{success:false}` with an explanatory message +- **AND** no import is attempted + +#### Scenario: Forced reload + +- **WHEN** an admin triggers the reload endpoint +- **THEN** `importFromApp` is called with `force:true` + +### REQ-OBS-004: Repair step bootstraps configuration on install/upgrade + +The `InitializeSettings` repair step SHALL run during app +install/upgrade, calling `SettingsService::reloadConfiguration()` to +force-import the bundled register configuration so a freshly installed +OpenBuilt has its registers and schemas provisioned without a manual +admin action. `getName()` SHALL return a human-readable step name and +`run(IOutput $output)` SHALL execute the import and surface its outcome +through the repair output / logger. + +#### Scenario: Install triggers import + +- **WHEN** the OpenBuilt app is installed or upgraded +- **THEN** the `InitializeSettings` repair step runs + `reloadConfiguration()` to provision registers + +### REQ-OBS-005: Liveness and metrics probe endpoints + +`HealthController::index()` SHALL return `{"status":"ok"}` with HTTP +200 for an authenticated caller and `{"error":"Unauthenticated."}` +with HTTP 401 otherwise. `MetricsController::index()` SHALL return a +Prometheus-shaped payload — currently `{"metrics":[]}` — with HTTP 200 +for an authenticated caller and HTTP 401 otherwise. Both endpoints are +declared `#[NoAdminRequired] #[NoCSRFRequired]` so probes can reach +them without admin rights or a CSRF token, but they still require an +authenticated session. + +#### Scenario: Health probe + +- **WHEN** an authenticated caller hits the health endpoint +- **THEN** the response is `{"status":"ok"}` with HTTP 200 + +#### Scenario: Metrics probe + +- **WHEN** an authenticated caller hits the metrics endpoint +- **THEN** the response is `{"metrics":[]}` with HTTP 200 + +**Notes:** The metrics payload is intentionally empty in the current +phase; counters/gauges for export-job throughput and nav-entry counts +are planned but not yet implemented. Both probes require an +authenticated session even though they are CSRF/admin-exempt, which is +stricter than a typical anonymous orchestrator probe — flagged as +observed behaviour, not aspirational. diff --git a/openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md b/openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md new file mode 100644 index 00000000..0190c3ce --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-settings-and-observability/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] task-1: settings-and-observability#REQ-OBS-001 — Settings read returns config plus environment metadata (retroactive annotation) +- [x] task-2: settings-and-observability#REQ-OBS-002 — Settings update persists only whitelisted keys (retroactive annotation) +- [x] task-3: settings-and-observability#REQ-OBS-003 — Configuration import is idempotent and force-reloadable (retroactive annotation) +- [x] task-4: settings-and-observability#REQ-OBS-004 — Repair step bootstraps configuration on install/upgrade (retroactive annotation) +- [x] task-5: settings-and-observability#REQ-OBS-005 — Liveness and metrics probe endpoints (retroactive annotation) diff --git a/openspec/specs/settings-and-observability/spec.md b/openspec/specs/settings-and-observability/spec.md new file mode 100644 index 00000000..6bc4741b --- /dev/null +++ b/openspec/specs/settings-and-observability/spec.md @@ -0,0 +1,147 @@ +--- +retrofit: true +--- + +# settings-and-observability Specification + +## Purpose + +OpenBuilt exposes a small administrative surface that lets the frontend +read and write app-level configuration (the OpenRegister register key), +discover whether OpenRegister is installed, and learn whether the +current user is an admin. The same surface re-imports the bundled +`openbuilt_register.json` configuration into OpenRegister — both +lazily (idempotent first-boot import) and forcibly (admin "Reload" +action / repair step). Two lightweight probe endpoints expose liveness +and a placeholder metrics payload for container orchestrators and +load balancers. + +This capability is observed behaviour of the `SettingsService`, +`SettingsController`, `InitializeSettings` repair step, and the +`HealthController` / `MetricsController` probe endpoints. + +## Requirements + +### REQ-OBS-001: Settings read returns config plus environment metadata + +`SettingsService::getSettings()` SHALL return a flat array containing +every key in `CONFIG_KEYS` (currently `register`), read from +`IAppConfig` for the `openbuilt` app with an empty-string default, +merged with two computed metadata fields: `openregisters` (true when +the `openregister` app is installed, via +`isOpenRegisterAvailable()`) and `isAdmin` (true when a user is signed +in and is a member of the admin group). `SettingsController::index()` +SHALL return this array as a `JSONResponse`, rejecting unauthenticated +callers with HTTP 401. + +#### Scenario: Authenticated read + +- **WHEN** a signed-in user calls the settings index endpoint +- **THEN** the response contains the `register` config value plus + `openregisters` and `isAdmin` booleans +- **AND** the HTTP status is 200 + +#### Scenario: Unauthenticated read + +- **WHEN** an unauthenticated request hits the settings index endpoint +- **THEN** the response is `{"error":"Unauthenticated."}` with HTTP 401 + +### REQ-OBS-002: Settings update persists only whitelisted keys + +`SettingsService::updateSettings(array $data)` SHALL iterate over +`CONFIG_KEYS` and persist via `IAppConfig::setValueString` only those +keys present in the supplied data — any other input keys are ignored. +It SHALL return the freshly re-read settings array (REQ-OBS-001 shape). +`SettingsController::create()` SHALL read the request params, call +`updateSettings`, and return `{"success":true,"config":}`, +rejecting unauthenticated callers with HTTP 401. + +#### Scenario: Persist a known key + +- **WHEN** an authenticated caller POSTs `{"register":"openbuilt"}` +- **THEN** the `register` app-config value is set to `openbuilt` +- **AND** the response echoes `success:true` and the updated config + +#### Scenario: Unknown key ignored + +- **WHEN** the payload contains a key not in `CONFIG_KEYS` +- **THEN** that key is not written to app config + +### REQ-OBS-003: Configuration import is idempotent and force-reloadable + +`SettingsService::loadConfiguration()` SHALL import the bundled +`lib/Settings/openbuilt_register.json` into OpenRegister via +`ConfigurationService::importFromApp` with `force: false`, relying on +OR to short-circuit an already-imported configuration. +`reloadConfiguration()` SHALL perform the same import with +`force: true`. The shared private `doLoadConfiguration(bool $force)` +SHALL return `{success:false,message}` when OpenRegister is absent, the +config file is missing, unreadable, or unparseable, and +`{success:true,message,version}` on a non-empty import result — the +version being taken from the import result or falling back to the +config file's `info.version`. Any thrown error SHALL be logged and +returned as `{success:false,message:}`. +`SettingsController::load()` SHALL invoke `reloadConfiguration()` and +return its result, rejecting unauthenticated callers with HTTP 401. + +#### Scenario: First import succeeds + +- **WHEN** `loadConfiguration()` runs with OpenRegister installed and a + valid config file +- **THEN** `importFromApp` is called with `force:false` +- **AND** a `{success:true, version}` result is returned + +#### Scenario: OpenRegister absent + +- **WHEN** the import runs and OpenRegister is not installed +- **THEN** the result is `{success:false}` with an explanatory message +- **AND** no import is attempted + +#### Scenario: Forced reload + +- **WHEN** an admin triggers the reload endpoint +- **THEN** `importFromApp` is called with `force:true` + +### REQ-OBS-004: Repair step bootstraps configuration on install/upgrade + +The `InitializeSettings` repair step SHALL run during app +install/upgrade, calling `SettingsService::reloadConfiguration()` to +force-import the bundled register configuration so a freshly installed +OpenBuilt has its registers and schemas provisioned without a manual +admin action. `getName()` SHALL return a human-readable step name and +`run(IOutput $output)` SHALL execute the import and surface its outcome +through the repair output / logger. + +#### Scenario: Install triggers import + +- **WHEN** the OpenBuilt app is installed or upgraded +- **THEN** the `InitializeSettings` repair step runs + `reloadConfiguration()` to provision registers + +### REQ-OBS-005: Liveness and metrics probe endpoints + +`HealthController::index()` SHALL return `{"status":"ok"}` with HTTP +200 for an authenticated caller and `{"error":"Unauthenticated."}` +with HTTP 401 otherwise. `MetricsController::index()` SHALL return a +Prometheus-shaped payload — currently `{"metrics":[]}` — with HTTP 200 +for an authenticated caller and HTTP 401 otherwise. Both endpoints are +declared `#[NoAdminRequired] #[NoCSRFRequired]` so probes can reach +them without admin rights or a CSRF token, but they still require an +authenticated session. + +#### Scenario: Health probe + +- **WHEN** an authenticated caller hits the health endpoint +- **THEN** the response is `{"status":"ok"}` with HTTP 200 + +#### Scenario: Metrics probe + +- **WHEN** an authenticated caller hits the metrics endpoint +- **THEN** the response is `{"metrics":[]}` with HTTP 200 + +**Notes:** The metrics payload is intentionally empty in the current +phase; counters/gauges for export-job throughput and nav-entry counts +are planned but not yet implemented. Both probes require an +authenticated session even though they are CSRF/admin-exempt, which is +stricter than a typical anonymous orchestrator probe — flagged as +observed behaviour, not aspirational. From 78734d8ca636da7a79c7cf65fa9460a98bfb05f8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 25 May 2026 23:30:24 +0200 Subject: [PATCH 2/4] retrofit: reverse-spec schema-designer-ui (5 REQs) + annotate ~111 methods --- .../design.md | 5 + .../proposal.md | 9 + .../specs/schema-designer-ui/spec.md | 126 +++++++++++ .../tasks.md | 7 + openspec/specs/schema-designer-ui/spec.md | 126 +++++++++++ .../schema-editor/AggregationEditor.vue | 6 + .../schema-editor/CalculationEditor.vue | 6 + src/components/schema-editor/FieldEditor.vue | 131 ++++++++++++ .../schema-editor/LifecycleEditor.vue | 130 ++++++++++++ .../schema-editor/NotificationEditor.vue | 6 + .../schema-editor/RelationEditor.vue | 55 +++++ .../schema-editor/SchemaHeaderForm.vue | 32 +++ .../schema-editor/SchemaListPanel.vue | 47 +++++ src/components/schema-editor/WidgetEditor.vue | 37 ++++ src/modals/AddSchemaDialog.vue | 39 ++++ src/modals/DeleteFieldDialog.vue | 19 ++ src/modals/DeleteSchemaDialog.vue | 32 +++ src/store/schemas.js | 4 + src/views/SchemaDesigner.vue | 195 ++++++++++++++++++ 19 files changed, 1012 insertions(+) create mode 100644 openspec/changes/retrofit-2026-05-25-schema-designer-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-25-schema-designer-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-25-schema-designer-ui/specs/schema-designer-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md create mode 100644 openspec/specs/schema-designer-ui/spec.md diff --git a/openspec/changes/retrofit-2026-05-25-schema-designer-ui/design.md b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/design.md new file mode 100644 index 00000000..31cb5194 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/design.md @@ -0,0 +1,5 @@ +# Design — schema-designer-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation work. +The Schema Designer code already ships; this records its observed behaviour as numbered REQs +so gate-16 spec-coverage can trace each method to a requirement. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-25-schema-designer-ui/proposal.md b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/proposal.md new file mode 100644 index 00000000..ae5ca01d --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/proposal.md @@ -0,0 +1,9 @@ +# Retrofit — schema-designer-ui + +Describes observed behaviour of the Schema Designer UI (SchemaDesigner view, +schema-editor/* sub-editors, schema modal dialogs, schemas store) as 5 new REQs. +Code already exists — this change retroactively specifies it. + +## Approach +- Describe observed inputs, outputs, validation gates, and emit contracts per component +- REQs match behaviour, not aspiration diff --git a/openspec/changes/retrofit-2026-05-25-schema-designer-ui/specs/schema-designer-ui/spec.md b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/specs/schema-designer-ui/spec.md new file mode 100644 index 00000000..0e261070 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/specs/schema-designer-ui/spec.md @@ -0,0 +1,126 @@ +--- +retrofit: true +--- + +# schema-designer-ui Specification + +## Purpose + +The Schema Designer is OpenBuilt's visual editor for authoring an +Application version's OpenRegister schemas: their header (slug, title, +description, version), property fields, lifecycle state machine, +relations, widgets, and derived-value blocks (aggregations, +calculations, notifications). The `SchemaDesigner` view orchestrates +list/detail navigation, stages edits in memory, validates them, and +persists via OpenRegister's runtime schema CRUD. Sub-editors emit +their slices upward; modal dialogs gate create/delete actions; the +schemas store resolves the per-app register. + +This capability is observed behaviour of the `SchemaDesigner` view, +the `src/components/schema-editor/*` components, the schema modal +dialogs, and the `schemas` store. + +## Requirements + +### REQ-OBSDUI-001: Schema list panel and create/delete gating + +The `SchemaListPanel` SHALL render the version's schemas with a +property count and lifecycle label per row, emit an open event when a +row is activated, and route add/delete intents through confirmation. +`AddSchemaDialog` SHALL validate the new schema's slug/title before +confirming; `DeleteSchemaDialog` SHALL require an explicit confirmation +gate before allowing delete; `DeleteFieldDialog` SHALL confirm +field-level removal. The `schemas` store SHALL resolve the register +slug for a given application (`registerSlugForApp`). + +#### Scenario: Open a schema + +- **WHEN** the user activates a schema row +- **THEN** the panel emits an open event carrying the schema identity +- **AND** the designer loads that schema's detail + +#### Scenario: Guarded delete + +- **WHEN** the user requests schema or field deletion +- **THEN** a confirmation dialog gates the destructive action until + confirmed + +### REQ-OBSDUI-002: Field editor authors typed properties + +`FieldEditor` SHALL let the user add, remove, reorder (move up/down), +and edit schema fields, exposing type and cardinality option lists, +coercing numeric inputs (`toIntOrNull`, `toNumberOrNull`), validating +field-name uniqueness/presence (`nameError`), and emitting the updated +field array upward. Removal SHALL be guarded by a request/confirm/cancel +flow. + +#### Scenario: Add and edit a field + +- **WHEN** the user adds a field and sets its type and validation +- **THEN** the editor emits the updated fields array +- **AND** numeric validation inputs are coerced to numbers or null + +#### Scenario: Reorder fields + +- **WHEN** the user moves a field up or down +- **THEN** the field order in the emitted array changes accordingly + +### REQ-OBSDUI-003: Lifecycle editor authors the state machine + +`LifecycleEditor` SHALL let the user add/remove/edit states, +transitions, and per-transition actions, expose action/state option +lists, validate state-name correctness (`stateNameValid`), track the +number of initial states (`initialCount`), enforce exactly one initial +state via `setInitial`, and emit updated states and transitions upward. + +#### Scenario: Define a state machine + +- **WHEN** the user adds states, marks one initial, and adds + transitions between them +- **THEN** the editor emits the updated states and transitions +- **AND** marking a new initial state clears the previous one + +### REQ-OBSDUI-004: Relations, widgets, and derived-value editors + +`RelationEditor` SHALL author schema relations (add/remove/update with +cardinality and target-schema option lists, emitting the relations +array). `WidgetEditor` SHALL author `x-openregister-widgets` rows +(add/remove/update, JSON-validating each widget's config and emitting +the widgets array). The `AggregationEditor`, `CalculationEditor`, and +`NotificationEditor` SHALL render a formatted, human-readable summary +of their respective derived-value blocks. + +#### Scenario: Author a relation + +- **WHEN** the user adds a relation with a target schema and cardinality +- **THEN** the editor emits the updated relations array + +#### Scenario: Invalid widget config blocks + +- **WHEN** a widget's config JSON is malformed +- **THEN** the widget row surfaces a config error + +### REQ-OBSDUI-005: Designer stages, validates, and persists schema edits + +The `SchemaDesigner` view SHALL resolve the active ApplicationVersion, +load the schema list and a selected schema's detail, stage edits in +memory, and compose a canonical schema body from the staged slices +(`composeSchemaBody`, `bodyToStaged`). Save SHALL be gated +(`canSave`) by: dirty-state (`hasStagedChanges`), field-name uniqueness +(`fieldNamesUnique`), exactly one initial lifecycle state +(`hasInitialLifecycleState`), and no widget config errors. Save SHALL +persist via the schemas store / OR runtime CRUD; discard SHALL revert +staged edits; add/delete SHALL update the list. Each sub-editor change +handler (`onFieldsChange`, `onStatesChange`, `onTransitionsChange`, +`onRelationsChange`, `onWidgetsChange`, `onHeaderChange`) SHALL update +the staged body. + +#### Scenario: Save a valid edit + +- **WHEN** the staged schema is dirty and passes all validation gates +- **THEN** Save is enabled and persists the composed body via the store + +#### Scenario: Validation blocks save + +- **WHEN** field names collide or no initial lifecycle state is set +- **THEN** `canSave` is false and Save is disabled diff --git a/openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md new file mode 100644 index 00000000..ed1bc445 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] task-1: schema-designer-ui#REQ-OBSDUI-001 — Schema list panel and create/delete gating (retroactive annotation) +- [x] task-2: schema-designer-ui#REQ-OBSDUI-002 — Field editor authors typed properties (retroactive annotation) +- [x] task-3: schema-designer-ui#REQ-OBSDUI-003 — Lifecycle editor authors the state machine (retroactive annotation) +- [x] task-4: schema-designer-ui#REQ-OBSDUI-004 — Relations, widgets, and derived-value editors (retroactive annotation) +- [x] task-5: schema-designer-ui#REQ-OBSDUI-005 — Designer stages, validates, and persists schema edits (retroactive annotation) diff --git a/openspec/specs/schema-designer-ui/spec.md b/openspec/specs/schema-designer-ui/spec.md new file mode 100644 index 00000000..0e261070 --- /dev/null +++ b/openspec/specs/schema-designer-ui/spec.md @@ -0,0 +1,126 @@ +--- +retrofit: true +--- + +# schema-designer-ui Specification + +## Purpose + +The Schema Designer is OpenBuilt's visual editor for authoring an +Application version's OpenRegister schemas: their header (slug, title, +description, version), property fields, lifecycle state machine, +relations, widgets, and derived-value blocks (aggregations, +calculations, notifications). The `SchemaDesigner` view orchestrates +list/detail navigation, stages edits in memory, validates them, and +persists via OpenRegister's runtime schema CRUD. Sub-editors emit +their slices upward; modal dialogs gate create/delete actions; the +schemas store resolves the per-app register. + +This capability is observed behaviour of the `SchemaDesigner` view, +the `src/components/schema-editor/*` components, the schema modal +dialogs, and the `schemas` store. + +## Requirements + +### REQ-OBSDUI-001: Schema list panel and create/delete gating + +The `SchemaListPanel` SHALL render the version's schemas with a +property count and lifecycle label per row, emit an open event when a +row is activated, and route add/delete intents through confirmation. +`AddSchemaDialog` SHALL validate the new schema's slug/title before +confirming; `DeleteSchemaDialog` SHALL require an explicit confirmation +gate before allowing delete; `DeleteFieldDialog` SHALL confirm +field-level removal. The `schemas` store SHALL resolve the register +slug for a given application (`registerSlugForApp`). + +#### Scenario: Open a schema + +- **WHEN** the user activates a schema row +- **THEN** the panel emits an open event carrying the schema identity +- **AND** the designer loads that schema's detail + +#### Scenario: Guarded delete + +- **WHEN** the user requests schema or field deletion +- **THEN** a confirmation dialog gates the destructive action until + confirmed + +### REQ-OBSDUI-002: Field editor authors typed properties + +`FieldEditor` SHALL let the user add, remove, reorder (move up/down), +and edit schema fields, exposing type and cardinality option lists, +coercing numeric inputs (`toIntOrNull`, `toNumberOrNull`), validating +field-name uniqueness/presence (`nameError`), and emitting the updated +field array upward. Removal SHALL be guarded by a request/confirm/cancel +flow. + +#### Scenario: Add and edit a field + +- **WHEN** the user adds a field and sets its type and validation +- **THEN** the editor emits the updated fields array +- **AND** numeric validation inputs are coerced to numbers or null + +#### Scenario: Reorder fields + +- **WHEN** the user moves a field up or down +- **THEN** the field order in the emitted array changes accordingly + +### REQ-OBSDUI-003: Lifecycle editor authors the state machine + +`LifecycleEditor` SHALL let the user add/remove/edit states, +transitions, and per-transition actions, expose action/state option +lists, validate state-name correctness (`stateNameValid`), track the +number of initial states (`initialCount`), enforce exactly one initial +state via `setInitial`, and emit updated states and transitions upward. + +#### Scenario: Define a state machine + +- **WHEN** the user adds states, marks one initial, and adds + transitions between them +- **THEN** the editor emits the updated states and transitions +- **AND** marking a new initial state clears the previous one + +### REQ-OBSDUI-004: Relations, widgets, and derived-value editors + +`RelationEditor` SHALL author schema relations (add/remove/update with +cardinality and target-schema option lists, emitting the relations +array). `WidgetEditor` SHALL author `x-openregister-widgets` rows +(add/remove/update, JSON-validating each widget's config and emitting +the widgets array). The `AggregationEditor`, `CalculationEditor`, and +`NotificationEditor` SHALL render a formatted, human-readable summary +of their respective derived-value blocks. + +#### Scenario: Author a relation + +- **WHEN** the user adds a relation with a target schema and cardinality +- **THEN** the editor emits the updated relations array + +#### Scenario: Invalid widget config blocks + +- **WHEN** a widget's config JSON is malformed +- **THEN** the widget row surfaces a config error + +### REQ-OBSDUI-005: Designer stages, validates, and persists schema edits + +The `SchemaDesigner` view SHALL resolve the active ApplicationVersion, +load the schema list and a selected schema's detail, stage edits in +memory, and compose a canonical schema body from the staged slices +(`composeSchemaBody`, `bodyToStaged`). Save SHALL be gated +(`canSave`) by: dirty-state (`hasStagedChanges`), field-name uniqueness +(`fieldNamesUnique`), exactly one initial lifecycle state +(`hasInitialLifecycleState`), and no widget config errors. Save SHALL +persist via the schemas store / OR runtime CRUD; discard SHALL revert +staged edits; add/delete SHALL update the list. Each sub-editor change +handler (`onFieldsChange`, `onStatesChange`, `onTransitionsChange`, +`onRelationsChange`, `onWidgetsChange`, `onHeaderChange`) SHALL update +the staged body. + +#### Scenario: Save a valid edit + +- **WHEN** the staged schema is dirty and passes all validation gates +- **THEN** Save is enabled and persists the composed body via the store + +#### Scenario: Validation blocks save + +- **WHEN** field names collide or no initial lifecycle state is set +- **THEN** `canSave` is false and Save is disabled diff --git a/src/components/schema-editor/AggregationEditor.vue b/src/components/schema-editor/AggregationEditor.vue index c15693e3..f00a5538 100644 --- a/src/components/schema-editor/AggregationEditor.vue +++ b/src/components/schema-editor/AggregationEditor.vue @@ -33,6 +33,12 @@ export default { aggregations: { type: [Object, Array], default: null }, }, computed: { + /** + * Render the aggregations block as pretty JSON for read-only display. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {string} Formatted JSON, or empty string on error. + */ formatted() { try { return JSON.stringify(this.aggregations, null, 2) diff --git a/src/components/schema-editor/CalculationEditor.vue b/src/components/schema-editor/CalculationEditor.vue index 9b3716c4..f6267916 100644 --- a/src/components/schema-editor/CalculationEditor.vue +++ b/src/components/schema-editor/CalculationEditor.vue @@ -33,6 +33,12 @@ export default { calculations: { type: [Object, Array], default: null }, }, computed: { + /** + * Render the calculations block as pretty JSON for read-only display. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {string} Formatted JSON, or empty string on error. + */ formatted() { try { return JSON.stringify(this.calculations, null, 2) diff --git a/src/components/schema-editor/FieldEditor.vue b/src/components/schema-editor/FieldEditor.vue index 7ab9fa4e..78cc05f6 100644 --- a/src/components/schema-editor/FieldEditor.vue +++ b/src/components/schema-editor/FieldEditor.vue @@ -235,21 +235,45 @@ export default { } }, computed: { + /** + * Build the field-type picker options. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {Array} Option objects. + */ typeOptions() { return SUPPORTED_TYPES.map((value) => ({ value, label: this.t('openbuilt', value), })) }, + /** + * Build the array-items-type picker options. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {Array} Option objects. + */ itemsTypeOptions() { return ITEMS_TYPES.map((value) => ({ value, label: this.t('openbuilt', value), })) }, + /** + * Build relation target-schema picker options. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {Array} Option objects. + */ schemaOptions() { return this.schemaSlugs.map((slug) => ({ value: slug, label: slug })) }, + /** + * Build cardinality picker options (one/many). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {Array} Option objects. + */ cardinalityOptions() { return CARDINALITIES.map((value) => ({ value, @@ -260,15 +284,44 @@ export default { }, }, methods: { + /** + * Resolve the selected field-type option. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {string} type Field type. + * @return {object} Matching option. + */ typeOption(type) { return this.typeOptions.find((o) => o.value === type) || this.typeOptions[0] }, + /** + * Resolve the selected target-schema option. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {string} value Schema slug. + * @return {object|null} Matching option. + */ schemaOption(value) { return this.schemaOptions.find((o) => o.value === value) || null }, + /** + * Resolve the selected cardinality option. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {string} value Cardinality. + * @return {object} Matching option. + */ cardinalityOption(value) { return this.cardinalityOptions.find((o) => o.value === value) || this.cardinalityOptions[0] }, + /** + * Validate a field name: presence, pattern, and uniqueness. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {object} field Field row. + * @param {number} index Row index. + * @return {string} Error message, or empty when valid. + */ nameError(field, index) { if (!field.name) { return this.t('openbuilt', 'Name is required.') @@ -282,6 +335,13 @@ export default { } return '' }, + /** + * Coerce an input to an integer or null. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {*} value Raw input. + * @return {number|null} Parsed integer or null. + */ toIntOrNull(value) { if (value === '' || value == null) { return null @@ -289,6 +349,13 @@ export default { const parsed = parseInt(value, 10) return Number.isFinite(parsed) ? parsed : null }, + /** + * Coerce an input to a number or null. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {*} value Raw input. + * @return {number|null} Parsed number or null. + */ toNumberOrNull(value) { if (value === '' || value == null) { return null @@ -296,9 +363,22 @@ export default { const parsed = Number(value) return Number.isFinite(parsed) ? parsed : null }, + /** + * Emit the updated fields array to the parent. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {Array} next Next fields array. + * @return {void} + */ emitFields(next) { this.$emit('update:fields', next) }, + /** + * Append a new blank field row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {void} + */ addField() { const next = this.fields.slice() next.push({ @@ -312,6 +392,15 @@ export default { }) this.emitFields(next) }, + /** + * Update a single field property; resets validation on type change. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {number} index Row index. + * @param {string} key Property key. + * @param {*} value New value. + * @return {void} + */ updateField(index, key, value) { const next = this.fields.slice() const current = { ...next[index] } @@ -324,6 +413,15 @@ export default { next[index] = current this.emitFields(next) }, + /** + * Set or clear a single validation slot on a field. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {number} index Row index. + * @param {string} key Validation key. + * @param {*} value New value (empty clears the slot). + * @return {void} + */ updateValidation(index, key, value) { const next = this.fields.slice() const current = { ...next[index] } @@ -337,6 +435,13 @@ export default { next[index] = current this.emitFields(next) }, + /** + * Move a field up one position. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {number} index Row index. + * @return {void} + */ moveUp(index) { if (index === 0) { return @@ -346,6 +451,13 @@ export default { next.splice(index - 1, 0, moved) this.emitFields(next) }, + /** + * Move a field down one position. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {number} index Row index. + * @return {void} + */ moveDown(index) { if (index === this.fields.length - 1) { return @@ -355,11 +467,24 @@ export default { next.splice(index + 1, 0, moved) this.emitFields(next) }, + /** + * Open the remove-field confirmation dialog. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {number} index Row index. + * @return {void} + */ requestRemove(index) { this.pendingRemoveIndex = index this.pendingRemoveName = this.fields[index]?.name || this.t('openbuilt', '(unnamed)') this.removeDialogOpen = true }, + /** + * Confirm removal of the pending field. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {void} + */ confirmRemove() { if (this.pendingRemoveIndex < 0) { this.removeDialogOpen = false @@ -370,6 +495,12 @@ export default { this.emitFields(next) this.cancelRemove() }, + /** + * Cancel the pending field removal and reset state. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {void} + */ cancelRemove() { this.removeDialogOpen = false this.pendingRemoveIndex = -1 diff --git a/src/components/schema-editor/LifecycleEditor.vue b/src/components/schema-editor/LifecycleEditor.vue index 1af757c8..3396cd52 100644 --- a/src/components/schema-editor/LifecycleEditor.vue +++ b/src/components/schema-editor/LifecycleEditor.vue @@ -196,14 +196,32 @@ export default { }, emits: ['update:states', 'update:transitions'], computed: { + /** + * Count how many states are flagged initial. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @return {number} Initial-state count. + */ initialCount() { return this.states.filter((s) => s.initial).length }, + /** + * Build state picker options from named states. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @return {Array} Option objects. + */ stateOptions() { return this.states .filter((s) => s.name) .map((s) => ({ value: s.name, label: s.label || s.name })) }, + /** + * Build transition-action-type picker options. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @return {Array} Option objects. + */ actionOptions() { return ACTION_TYPES.map((value) => ({ value, @@ -212,12 +230,34 @@ export default { }, }, methods: { + /** + * Resolve the selected state option. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {string} value State name. + * @return {object|null} Matching option. + */ stateOption(value) { return this.stateOptions.find((o) => o.value === value) || null }, + /** + * Resolve the selected action-type option. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {string} value Action type. + * @return {object} Matching option. + */ actionOption(value) { return this.actionOptions.find((o) => o.value === value) || this.actionOptions[0] }, + /** + * Validate a state name: pattern and uniqueness. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {object} state State row. + * @param {number} index Row index. + * @return {boolean} True when valid. + */ stateNameValid(state, index) { if (!STATE_NAME_PATTERN.test(state.name || '')) { return false @@ -225,12 +265,32 @@ export default { const duplicate = this.states.some((other, otherIndex) => otherIndex !== index && other.name === state.name) return !duplicate }, + /** + * Emit the updated states array. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {Array} next Next states array. + * @return {void} + */ emitStates(next) { this.$emit('update:states', next) }, + /** + * Emit the updated transitions array. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {Array} next Next transitions array. + * @return {void} + */ emitTransitions(next) { this.$emit('update:transitions', next) }, + /** + * Append a new state (first state defaults to initial). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @return {void} + */ addState() { const next = this.states.slice() next.push({ @@ -241,20 +301,49 @@ export default { }) this.emitStates(next) }, + /** + * Update a single field of a state row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} index Row index. + * @param {string} key Field key. + * @param {*} value New value. + * @return {void} + */ updateState(index, key, value) { const next = this.states.slice() next[index] = { ...next[index], [key]: value } this.emitStates(next) }, + /** + * Mark a single state as the initial one (clears the others). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} index Row index. + * @return {void} + */ setInitial(index) { const next = this.states.map((s, i) => ({ ...s, initial: i === index })) this.emitStates(next) }, + /** + * Remove a state row by index. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} index Row index. + * @return {void} + */ removeState(index) { const next = this.states.slice() next.splice(index, 1) this.emitStates(next) }, + /** + * Append a new transition between the first two states. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @return {void} + */ addTransition() { const firstState = this.states[0]?.name || '' const secondState = this.states[1]?.name || firstState @@ -268,16 +357,39 @@ export default { }) this.emitTransitions(next) }, + /** + * Update a single field of a transition row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} index Row index. + * @param {string} key Field key. + * @param {*} value New value. + * @return {void} + */ updateTransition(index, key, value) { const next = this.transitions.slice() next[index] = { ...next[index], [key]: value } this.emitTransitions(next) }, + /** + * Remove a transition row by index. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} index Row index. + * @return {void} + */ removeTransition(index) { const next = this.transitions.slice() next.splice(index, 1) this.emitTransitions(next) }, + /** + * Append a new action to a transition. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} tIndex Transition index. + * @return {void} + */ addAction(tIndex) { const next = this.transitions.slice() const transition = { ...next[tIndex] } @@ -291,6 +403,16 @@ export default { next[tIndex] = transition this.emitTransitions(next) }, + /** + * Update a single field of a transition action. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} tIndex Transition index. + * @param {number} aIndex Action index. + * @param {string} key Field key. + * @param {*} value New value. + * @return {void} + */ updateAction(tIndex, aIndex, key, value) { const next = this.transitions.slice() const transition = { ...next[tIndex] } @@ -300,6 +422,14 @@ export default { next[tIndex] = transition this.emitTransitions(next) }, + /** + * Remove an action from a transition. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-3 + * @param {number} tIndex Transition index. + * @param {number} aIndex Action index. + * @return {void} + */ removeAction(tIndex, aIndex) { const next = this.transitions.slice() const transition = { ...next[tIndex] } diff --git a/src/components/schema-editor/NotificationEditor.vue b/src/components/schema-editor/NotificationEditor.vue index 37fdf540..f657c401 100644 --- a/src/components/schema-editor/NotificationEditor.vue +++ b/src/components/schema-editor/NotificationEditor.vue @@ -33,6 +33,12 @@ export default { notifications: { type: [Object, Array], default: null }, }, computed: { + /** + * Render the notifications block as pretty JSON for read-only display. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {string} Formatted JSON, or empty string on error. + */ formatted() { try { return JSON.stringify(this.notifications, null, 2) diff --git a/src/components/schema-editor/RelationEditor.vue b/src/components/schema-editor/RelationEditor.vue index 57dab669..72f560b1 100644 --- a/src/components/schema-editor/RelationEditor.vue +++ b/src/components/schema-editor/RelationEditor.vue @@ -82,9 +82,21 @@ export default { }, emits: ['update:relations'], computed: { + /** + * Build target-schema picker options from the available slugs. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {Array} Option objects. + */ schemaOptions() { return this.schemaSlugs.map((slug) => ({ value: slug, label: slug })) }, + /** + * Build cardinality picker options (one/many) with translated labels. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {Array} Option objects. + */ cardinalityOptions() { return CARDINALITIES.map((value) => ({ value, @@ -95,15 +107,42 @@ export default { }, }, methods: { + /** + * Resolve the selected target-schema option for a value. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {string} value Schema slug. + * @return {object|null} The matching option. + */ schemaOption(value) { return this.schemaOptions.find((o) => o.value === value) || null }, + /** + * Resolve the selected cardinality option for a value. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {string} value Cardinality. + * @return {object} The matching option (defaults to first). + */ cardinalityOption(value) { return this.cardinalityOptions.find((o) => o.value === value) || this.cardinalityOptions[0] }, + /** + * Emit the updated relations array to the parent. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {Array} next Next relations array. + * @return {void} + */ emitRelations(next) { this.$emit('update:relations', next) }, + /** + * Append a new blank relation row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {void} + */ addRelation() { const next = this.relations.slice() next.push({ @@ -115,11 +154,27 @@ export default { }) this.emitRelations(next) }, + /** + * Update a single field of a relation row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {number} index Row index. + * @param {string} key Field key. + * @param {*} value New value. + * @return {void} + */ updateRelation(index, key, value) { const next = this.relations.slice() next[index] = { ...next[index], [key]: value } this.emitRelations(next) }, + /** + * Remove a relation row by index. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {number} index Row index. + * @return {void} + */ removeRelation(index) { const next = this.relations.slice() next.splice(index, 1) diff --git a/src/components/schema-editor/SchemaHeaderForm.vue b/src/components/schema-editor/SchemaHeaderForm.vue index a667dbe3..262646f3 100644 --- a/src/components/schema-editor/SchemaHeaderForm.vue +++ b/src/components/schema-editor/SchemaHeaderForm.vue @@ -77,20 +77,52 @@ export default { } }, computed: { + /** + * Validate the slug against the lowercase-kebab pattern. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {boolean} True when valid. + */ slugValid() { return SLUG_PATTERN.test(this.value.slug || '') }, + /** + * Validate that a non-empty title is present. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {boolean} True when valid. + */ titleValid() { return !!(this.value.title && this.value.title.trim()) }, + /** + * Validate the version string against semver MAJOR.MINOR.PATCH. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {boolean} True when valid. + */ versionValid() { return SEMVER_PATTERN.test(this.value.version || '') }, + /** + * Aggregate validity of the whole header form. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {boolean} True when slug, title, and version are all valid. + */ allValid() { return this.slugValid && this.titleValid && this.versionValid }, }, methods: { + /** + * Emit an updated header object when a field changes. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {string} field Field name. + * @param {*} val New value. + * @return {void} + */ onChange(field, val) { this.$emit('input', { ...this.value, [field]: val }) }, diff --git a/src/components/schema-editor/SchemaListPanel.vue b/src/components/schema-editor/SchemaListPanel.vue index 94f143e8..1fdc6887 100644 --- a/src/components/schema-editor/SchemaListPanel.vue +++ b/src/components/schema-editor/SchemaListPanel.vue @@ -134,12 +134,26 @@ export default { getSlug(schema) { return schema.slug || (schema['@self'] && schema['@self'].slug) || schema.id || '' }, + /** + * Count the declared properties on a schema row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {object} schema Schema record. + * @return {number} Property count. + */ propertyCount(schema) { if (!schema || !schema.properties) { return 0 } return Object.keys(schema.properties).length }, + /** + * Build a human-readable lifecycle-state-count label. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {object} schema Schema record. + * @return {string} Lifecycle label. + */ lifecycleLabel(schema) { const lifecycle = schema && schema['x-openregister-lifecycle'] if (!lifecycle || !Array.isArray(lifecycle.states) || lifecycle.states.length === 0) { @@ -147,9 +161,23 @@ export default { } return this.n('openbuilt', '{n} lifecycle state', '{n} lifecycle states', lifecycle.states.length, { n: lifecycle.states.length }) }, + /** + * Emit an open event for the activated schema row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {object} schema Schema record. + * @return {void} + */ onOpen(schema) { this.$emit('open', this.getSlug(schema)) }, + /** + * Confirm the add-schema dialog: emit add and surface slug conflicts. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {object} payload New schema payload. + * @return {Promise<*>} The add result. + */ async onAddConfirm(payload) { this.addSubmitting = true this.addSlugError = '' @@ -169,15 +197,34 @@ export default { this.addSubmitting = false } }, + /** + * Open the delete-confirmation dialog for a schema. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {object} schema Schema record. + * @return {void} + */ requestDelete(schema) { this.pendingDeleteSlug = this.getSlug(schema) this.deleteOpen = true }, + /** + * Confirm deletion: emit delete and reset the pending state. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {void} + */ onDeleteConfirm() { this.$emit('delete', this.pendingDeleteSlug) this.deleteOpen = false this.pendingDeleteSlug = '' }, + /** + * Cancel the pending deletion. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {void} + */ cancelDelete() { this.deleteOpen = false this.pendingDeleteSlug = '' diff --git a/src/components/schema-editor/WidgetEditor.vue b/src/components/schema-editor/WidgetEditor.vue index 3fbd7872..791a5e2f 100644 --- a/src/components/schema-editor/WidgetEditor.vue +++ b/src/components/schema-editor/WidgetEditor.vue @@ -75,9 +75,22 @@ export default { }, emits: ['update:widgets'], methods: { + /** + * Emit the updated widgets array to the parent. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {Array} next The next widgets array. + * @return {void} + */ emitWidgets(next) { this.$emit('update:widgets', next) }, + /** + * Append a new blank widget row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @return {void} + */ addWidget() { const next = this.widgets.slice() next.push({ @@ -89,11 +102,28 @@ export default { }) this.emitWidgets(next) }, + /** + * Update a single field of a widget row. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {number} index Row index. + * @param {string} key Field key. + * @param {*} value New value. + * @return {void} + */ updateWidget(index, key, value) { const next = this.widgets.slice() next[index] = { ...next[index], [key]: value } this.emitWidgets(next) }, + /** + * Update a widget's config JSON, validating it parses. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {number} index Row index. + * @param {string} value Raw JSON string. + * @return {void} + */ updateConfig(index, value) { const next = this.widgets.slice() let error = '' @@ -105,6 +135,13 @@ export default { next[index] = { ...next[index], configJson: value, configError: error } this.emitWidgets(next) }, + /** + * Remove a widget row by index. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-4 + * @param {number} index Row index. + * @return {void} + */ removeWidget(index) { const next = this.widgets.slice() next.splice(index, 1) diff --git a/src/modals/AddSchemaDialog.vue b/src/modals/AddSchemaDialog.vue index 54007b38..e2cc8503 100644 --- a/src/modals/AddSchemaDialog.vue +++ b/src/modals/AddSchemaDialog.vue @@ -57,6 +57,12 @@ export default { } }, computed: { + /** + * Validate the new-schema slug, title, and semver version. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {boolean} True when the form is valid. + */ isValid() { return SLUG_PATTERN.test(this.local.slug) && this.local.title.trim().length > 0 @@ -64,6 +70,13 @@ export default { }, }, watch: { + /** + * Reset the local form when the dialog opens. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {boolean} value Open state. + * @return {void} + */ open(value) { if (value) { this.local = { slug: '', title: '', description: '', version: '0.1.0' } @@ -71,18 +84,44 @@ export default { }, }, methods: { + /** + * Merge partial form input into the local draft. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {object} value Partial form values. + * @return {void} + */ onInput(value) { this.local = { ...this.local, ...value } }, + /** + * Confirm only when valid, emitting the new schema payload. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {void} + */ onConfirm() { if (!this.isValid) { return } this.$emit('confirm', { ...this.local }) }, + /** + * Emit a cancel event. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {void} + */ onCancel() { this.$emit('cancel') }, + /** + * Sync the modal open state and emit cancel when closed. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {boolean} value Open state. + * @return {void} + */ onOpenUpdate(value) { this.$emit('update:open', value) if (!value) { diff --git a/src/modals/DeleteFieldDialog.vue b/src/modals/DeleteFieldDialog.vue index 1e0b5439..91349044 100644 --- a/src/modals/DeleteFieldDialog.vue +++ b/src/modals/DeleteFieldDialog.vue @@ -37,12 +37,31 @@ export default { }, emits: ['confirm', 'cancel', 'update:open'], methods: { + /** + * Confirm field removal. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {void} + */ onConfirm() { this.$emit('confirm') }, + /** + * Cancel field removal. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @return {void} + */ onCancel() { this.$emit('cancel') }, + /** + * Sync modal open state and emit cancel when closed. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-2 + * @param {boolean} value Open state. + * @return {void} + */ onOpenUpdate(value) { this.$emit('update:open', value) if (!value) { diff --git a/src/modals/DeleteSchemaDialog.vue b/src/modals/DeleteSchemaDialog.vue index 5ee6e973..4231bc85 100644 --- a/src/modals/DeleteSchemaDialog.vue +++ b/src/modals/DeleteSchemaDialog.vue @@ -51,11 +51,24 @@ export default { } }, computed: { + /** + * Enable delete only when the typed slug matches the target exactly. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {boolean} True when deletion is confirmed. + */ canDelete() { return this.typed === this.schemaSlug && this.schemaSlug !== '' }, }, watch: { + /** + * Clear the typed confirmation when the dialog closes. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {boolean} value Open state. + * @return {void} + */ open(value) { if (!value) { this.typed = '' @@ -63,6 +76,12 @@ export default { }, }, methods: { + /** + * Confirm deletion only when the confirmation gate is met. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {void} + */ onConfirm() { if (!this.canDelete) { return @@ -70,10 +89,23 @@ export default { this.$emit('confirm') this.typed = '' }, + /** + * Cancel deletion and reset the confirmation input. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @return {void} + */ onCancel() { this.typed = '' this.$emit('cancel') }, + /** + * Sync modal open state and emit cancel when closed. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-1 + * @param {boolean} value Open state. + * @return {void} + */ onOpenUpdate(value) { this.$emit('update:open', value) if (!value) { diff --git a/src/store/schemas.js b/src/store/schemas.js index 841773f1..17e90fe7 100644 --- a/src/store/schemas.js +++ b/src/store/schemas.js @@ -58,6 +58,8 @@ const useSchemasStoreRaw = createObjectStore(STORE_ID, { * @param {string} appSlug Virtual app slug (e.g. `hello-world`). * @param {string|undefined} [versionSlug] Optional version slug (e.g. `staging`). * @return {string} Register slug (e.g. `openbuilt-hello-world-staging`). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 */ export function registerSlugForApp(appSlug, versionSlug) { if (versionSlug && versionSlug !== '') { @@ -80,6 +82,8 @@ export function registerSlugForApp(appSlug, versionSlug) { * @param {string} appSlug Virtual app slug. * @param {string|undefined} [versionSlug] Optional version slug. * @return {object} Pinia store instance. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 */ export function useSchemasStore(appSlug, versionSlug) { const store = useSchemasStoreRaw() diff --git a/src/views/SchemaDesigner.vue b/src/views/SchemaDesigner.vue index 58654533..89e60cfb 100644 --- a/src/views/SchemaDesigner.vue +++ b/src/views/SchemaDesigner.vue @@ -173,11 +173,23 @@ export default { } }, computed: { + /** + * Resolve the active application slug from the route (seed fallback). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {string} App slug. + */ appSlug() { // Falls back to the hello-world seed app when reached via the // top-level /schemas shortcut (which carries no :slug param). return this.$route.params.slug || 'hello-world' }, + /** + * Resolve the active schema id from the route. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {string} Schema id. + */ schemaId() { return this.$route.params.schemaId || '' }, @@ -186,11 +198,18 @@ export default { * The underscore-prefix param name is OpenBuilt's system-reserved marker * to avoid colliding with user-defined `?version=` params. * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 * @return {string|undefined} */ versionSlug() { return this.$route.query._version || undefined }, + /** + * Resolve the schemas store bound to the active app/version register. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {object} Pinia store instance. + */ store() { // Re-creates the binding when appSlug changes; the store // factory re-registers the `schema` type to the per-app @@ -198,11 +217,23 @@ export default { // REQ-OBVR-007: pass versionSlug so the store targets the correct register. return useSchemasStore(this.appSlug, this.versionSlug) }, + /** + * List the slugs of the other schemas (relation targets). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {Array} Other schema slugs. + */ otherSchemaSlugs() { return this.schemas .map((s) => s.slug || (s['@self'] && s['@self'].slug) || s.id) .filter((slug) => slug && slug !== this.schemaId) }, + /** + * Project the staged schema header fields for the header form. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {object} Header value object. + */ headerValue() { if (!this.staged) { return { slug: '', title: '', description: '', version: '0.1.0' } @@ -217,12 +248,24 @@ export default { hasLifecycleStates() { return this.staged && this.staged.states && this.staged.states.length > 0 }, + /** + * Validate that exactly one initial lifecycle state is set. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {boolean} True when valid (or no lifecycle states). + */ hasInitialLifecycleState() { if (!this.hasLifecycleStates) { return true } return this.staged.states.filter((s) => s.initial).length === 1 }, + /** + * Validate that all staged field names are present and unique. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {boolean} True when unique. + */ fieldNamesUnique() { if (!this.staged) { return true @@ -239,6 +282,12 @@ export default { } return true }, + /** + * Gate Save on dirty-state plus all validation gates. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {boolean} True when the staged schema may be saved. + */ canSave() { if (!this.staged || this.saving) { return false @@ -255,6 +304,12 @@ export default { } return this.hasStagedChanges }, + /** + * Detect whether the staged body differs from the persisted one. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {boolean} True when there are unsaved changes. + */ hasStagedChanges() { if (!this.staged || !this.persisted) { return false @@ -265,23 +320,48 @@ export default { }, watch: { schemaId: { + /** + * Reload schema detail when the route schema id changes. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {void} + */ handler() { this.loadDetail() }, }, appSlug: { + /** + * Re-resolve version and refresh the list when the app changes. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {void} + */ handler() { this.resolveVersion() this.refreshList() }, }, versionSlug: { + /** + * Re-resolve version and refresh the list when the version changes. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {void} + */ handler() { this.resolveVersion() this.refreshList() }, }, }, + /** + * On mount: resolve version, load the list, and load detail if a schema + * is selected in the route. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {Promise} + */ async mounted() { // REQ-OBVR-004: resolve the active ApplicationVersion via useApplicationVersion. this.resolveVersion() @@ -300,6 +380,7 @@ export default { * NOTE: we do NOT call $router.replace() here — that would strip ?_version= * and break bookmarkability (REQ-OBVR-008). We just read what the URL contains. * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 * @return {void} */ resolveVersion() { @@ -325,6 +406,12 @@ export default { } }) }, + /** + * Fetch the schema collection and filter to this app/version register. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {Promise} + */ async refreshList() { this.loadingList = true try { @@ -353,6 +440,12 @@ export default { this.loadingList = false } }, + /** + * Load a single schema's detail and stage it for editing. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {Promise} + */ async loadDetail() { if (!this.schemaId) { this.staged = null @@ -382,6 +475,13 @@ export default { this.loadingDetail = false } }, + /** + * Convert a persisted schema body into the staged editor model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {object} body Persisted schema body. + * @return {object} Staged editor model. + */ bodyToStaged(body) { const fields = schemaToFields(body) const lifecycle = body['x-openregister-lifecycle'] @@ -401,6 +501,13 @@ export default { notifications: body['x-openregister-notifications'] || null, } }, + /** + * Compose a canonical schema body from the staged editor model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {object} staged Staged editor model. + * @return {object} Canonical schema body. + */ composeSchemaBody(staged) { const { properties, required, order } = fieldsToSchema(staged.fields) const body = { @@ -437,6 +544,13 @@ export default { } return body }, + /** + * Apply a header-form change into the staged model (slug locked). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {object} value Header values. + * @return {void} + */ onHeaderChange(value) { this.staged = { ...this.staged, @@ -446,21 +560,63 @@ export default { // slug is locked on detail view } }, + /** + * Apply a fields-editor change into the staged model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {Array} fields Updated fields. + * @return {void} + */ onFieldsChange(fields) { this.staged = { ...this.staged, fields } }, + /** + * Apply a states change into the staged model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {Array} states Updated states. + * @return {void} + */ onStatesChange(states) { this.staged = { ...this.staged, states } }, + /** + * Apply a transitions change into the staged model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {Array} transitions Updated transitions. + * @return {void} + */ onTransitionsChange(transitions) { this.staged = { ...this.staged, transitions } }, + /** + * Apply a relations change into the staged model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {Array} relations Updated relations. + * @return {void} + */ onRelationsChange(relations) { this.staged = { ...this.staged, relations } }, + /** + * Apply a widgets change into the staged model. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {Array} widgets Updated widgets. + * @return {void} + */ onWidgetsChange(widgets) { this.staged = { ...this.staged, widgets } }, + /** + * Create a new schema via the store, surfacing duplicate-slug errors. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {object} payload New schema payload. + * @return {Promise} + */ async addSchema(payload) { const body = { slug: payload.slug, @@ -493,6 +649,13 @@ export default { )) showSuccess(this.t('openbuilt', 'Schema {slug} created.', { slug: newSlug })) }, + /** + * Navigate to a schema's detail, preserving ?_version=. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {string} slug Schema slug. + * @return {void} + */ openSchema(slug) { // REQ-OBVR-006: use buildVersionedRoute to forward ?_version= on navigation. this.$router.push(buildVersionedRoute( @@ -501,6 +664,12 @@ export default { this.versionSlug, )) }, + /** + * Navigate back to the schema list, preserving ?_version=. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {void} + */ goToList() { // REQ-OBVR-006: use buildVersionedRoute to forward ?_version= on navigation. this.$router.push(buildVersionedRoute( @@ -509,6 +678,13 @@ export default { this.versionSlug, )) }, + /** + * Delete a schema via the store and refresh the list. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {string} slug Schema slug. + * @return {Promise} + */ async deleteSchema(slug) { const ok = await this.store.deleteObject(SCHEMA_TYPE, slug) if (!ok) { @@ -523,6 +699,12 @@ export default { this.goToList() } }, + /** + * Persist the composed schema body via the store (PUT on existing). + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {Promise} + */ async save() { if (!this.staged || this.saving) { return @@ -551,12 +733,25 @@ export default { this.saving = false } }, + /** + * Revert staged edits back to the persisted body. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @return {void} + */ discardChanges() { if (this.persisted) { this.staged = this.bodyToStaged(this.persisted) this.saveError = '' } }, + /** + * Extract a human-readable message from an error/response. + * + * @spec openspec/changes/retrofit-2026-05-25-schema-designer-ui/tasks.md#task-5 + * @param {*} e Error. + * @return {string} Message. + */ errorMessage(e) { if (!e) { return '' From f97af7d3401119534c4f750d62259aae41237fec Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 26 May 2026 00:41:16 +0200 Subject: [PATCH 3/4] retrofit: reverse-spec 7 UI capabilities (24 REQs) Add reverse-spec changes + published specs for the UI layer: - page-designer-ui (5 REQs) - application-detail-ui (5 REQs) - creation-wizard-ui (4 REQs) - version-routing-ui (4 REQs) - template-catalogue-ui (2 REQs) - exporter-ui (2 REQs) - frontend-foundation (5 REQs, incl. PreferencesController) All 7 changes pass openspec validate --strict. Observed behaviour only; no aspirational requirements. --- .../design.md | 13 ++ .../proposal.md | 17 ++ .../specs/application-detail-ui/spec.md | 122 ++++++++++++++ .../tasks.md | 7 + .../design.md | 11 ++ .../proposal.md | 16 ++ .../specs/creation-wizard-ui/spec.md | 98 ++++++++++++ .../tasks.md | 6 + .../retrofit-2026-05-26-exporter-ui/design.md | 10 ++ .../proposal.md | 14 ++ .../specs/exporter-ui/spec.md | 45 ++++++ .../retrofit-2026-05-26-exporter-ui/tasks.md | 4 + .../design.md | 13 ++ .../proposal.md | 19 +++ .../specs/frontend-foundation/spec.md | 96 +++++++++++ .../tasks.md | 7 + .../design.md | 14 ++ .../proposal.md | 16 ++ .../specs/page-designer-ui/spec.md | 150 ++++++++++++++++++ .../tasks.md | 7 + .../design.md | 10 ++ .../proposal.md | 14 ++ .../specs/template-catalogue-ui/spec.md | 54 +++++++ .../tasks.md | 4 + .../design.md | 11 ++ .../proposal.md | 17 ++ .../specs/version-routing-ui/spec.md | 78 +++++++++ .../tasks.md | 6 + openspec/specs/application-detail-ui/spec.md | 122 ++++++++++++++ openspec/specs/creation-wizard-ui/spec.md | 98 ++++++++++++ openspec/specs/exporter-ui/spec.md | 45 ++++++ openspec/specs/frontend-foundation/spec.md | 96 +++++++++++ openspec/specs/page-designer-ui/spec.md | 150 ++++++++++++++++++ openspec/specs/template-catalogue-ui/spec.md | 54 +++++++ openspec/specs/version-routing-ui/spec.md | 78 +++++++++ 35 files changed, 1522 insertions(+) create mode 100644 openspec/changes/retrofit-2026-05-26-application-detail-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-application-detail-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-application-detail-ui/specs/application-detail-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md create mode 100644 openspec/changes/retrofit-2026-05-26-creation-wizard-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-creation-wizard-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-creation-wizard-ui/specs/creation-wizard-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md create mode 100644 openspec/changes/retrofit-2026-05-26-exporter-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-exporter-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-exporter-ui/specs/exporter-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md create mode 100644 openspec/changes/retrofit-2026-05-26-frontend-foundation/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-frontend-foundation/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-frontend-foundation/specs/frontend-foundation/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md create mode 100644 openspec/changes/retrofit-2026-05-26-page-designer-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-page-designer-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-page-designer-ui/specs/page-designer-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md create mode 100644 openspec/changes/retrofit-2026-05-26-template-catalogue-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-template-catalogue-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-template-catalogue-ui/specs/template-catalogue-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md create mode 100644 openspec/changes/retrofit-2026-05-26-version-routing-ui/design.md create mode 100644 openspec/changes/retrofit-2026-05-26-version-routing-ui/proposal.md create mode 100644 openspec/changes/retrofit-2026-05-26-version-routing-ui/specs/version-routing-ui/spec.md create mode 100644 openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md create mode 100644 openspec/specs/application-detail-ui/spec.md create mode 100644 openspec/specs/creation-wizard-ui/spec.md create mode 100644 openspec/specs/exporter-ui/spec.md create mode 100644 openspec/specs/frontend-foundation/spec.md create mode 100644 openspec/specs/page-designer-ui/spec.md create mode 100644 openspec/specs/template-catalogue-ui/spec.md create mode 100644 openspec/specs/version-routing-ui/spec.md diff --git a/openspec/changes/retrofit-2026-05-26-application-detail-ui/design.md b/openspec/changes/retrofit-2026-05-26-application-detail-ui/design.md new file mode 100644 index 00000000..b9a35494 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-application-detail-ui/design.md @@ -0,0 +1,13 @@ +# Design — application-detail-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The Application detail UI already ships; this records its observed +behaviour as numbered REQs so gate-16 spec-coverage can trace each method to a +requirement. No behaviour is changed. + +`ApplicationDetailHeader` is registered as the `headerComponent` for the +`/applications/:objectId` detail route and acts as the maintainer cockpit: +version pills, KPI grid, activity sparkline, and the stacked overview widgets +(register / schemas / groups / pages / menu). `ApplicationCard` is the grid +tile on the index. `App.vue` is the app shell. The tabs and action bar surface +manifest, versions, icon, permissions, and publish actions. diff --git a/openspec/changes/retrofit-2026-05-26-application-detail-ui/proposal.md b/openspec/changes/retrofit-2026-05-26-application-detail-ui/proposal.md new file mode 100644 index 00000000..14ad89de --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-application-detail-ui/proposal.md @@ -0,0 +1,17 @@ +# Retrofit — application-detail-ui + +Describes observed behaviour of the Application detail UI layer — the +`ApplicationDetailHeader` cockpit, its overview widgets +(`applicationDetail/widgets/*`), the `ApplicationCard` grid tile, the +detail action bar (`ApplicationDetailActions`), the manifest-diff viewer +(`ManifestDiff`), the per-application tabs (`tabs/*`), the +`VirtualAppsActions` toolbar, and the `App.vue` shell — as 5 new REQs. + +Code already exists (it implements the `application-detail-overview` backend +capability). This change retroactively specifies the frontend behaviour at the +component-method level so gate-16 spec-coverage can trace each method. + +## Approach +- Describe observed computed surfaces, fetches, emit/open contracts, and + version selection per component. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-application-detail-ui/specs/application-detail-ui/spec.md b/openspec/changes/retrofit-2026-05-26-application-detail-ui/specs/application-detail-ui/spec.md new file mode 100644 index 00000000..334249f3 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-application-detail-ui/specs/application-detail-ui/spec.md @@ -0,0 +1,122 @@ +--- +retrofit: true +--- + +# application-detail-ui Specification + +## Purpose + +The Application detail UI is OpenBuilt's maintainer cockpit. The +`ApplicationDetailHeader` (registered as the detail-route `headerComponent`) +renders version pills, a four-card KPI grid, an activity sparkline, and the +stacked overview widgets (register / schemas / groups / pages / menu). The +`ApplicationCard` is the grid tile on the index. The action bar +(`ApplicationDetailActions`), tabs (`tabs/*`), and `VirtualAppsActions` drive +publish, permissions, manifest, versions, and icon actions. `ManifestDiff` +renders a version-to-version manifest diff, and `App.vue` is the app shell. + +This capability is observed behaviour of those components. It is the frontend +half of the `application-detail-overview` and `application-insights` backend +capabilities. + +## ADDED Requirements + +### Requirement: Detail header cockpit renders versions, KPIs, activity and refresh + +`ApplicationDetailHeader` SHALL bind the application object +(`object`, `objectId`, `appSlug`, `applicationName`, `applicationDescription`, +`applicationStatus`, `iconUrl`, `banner`), resolve and order the version chain +(`loadVersions`, `orderedVersions`, `visibleVersions`, `activeVersion`, +`activeVersionSlug`, `activeVersionUuid`, `selectVersion`, `selectedWindow`, +`windowOptions`), surface the active version's manifest-derived counts +(`activeManifest`, `activeMenu`, `activePages`, `activeSchemas`, `schemaCount`), +fetch and debounce insights (`fetchInsights`, `scheduleInsightsFetch`, +`sparklinePoints`, `totalActivityEvents`, `filesTooltip`), expose the production +pointer (`productionVersion`, `productionSemver`, `productionVersionUuid`, +`switchToProduction`), open permissions/promote flows (`onOpenPermissions`, +`onPromoteClick`, `callerRole`), refresh on demand (`refreshApplication`), and +clean up timers on `beforeDestroy`/`mounted`. + +#### Scenario: Select a version pill + +- **WHEN** the user activates a non-production version pill +- **THEN** the header switches the active version and re-derives the manifest + counts and insights window + +#### Scenario: Refresh insights window + +- **WHEN** the user changes the time window +- **THEN** the header debounces and re-fetches the version-scoped insights + +### Requirement: Overview widgets render rows with deep-links and inline actions + +The overview widgets SHALL each render their domain rows: `RegisterWidget` +read-only with an "Open in OpenRegister" deep-link (`registerSlug`, +`openInOpenRegister`); `SchemasWidget` with deep-link, count formatting, and an +inline add (`openSchema`, `addSchema`, `formatCount`); `GroupsWidget` with role +badges (`rows`, `roleLabel`, `memberLabel`, `openEditor`); `PagesWidget` +(`openPage`); and `MenuWidget` (`openEntry`). Each open/add action SHALL emit or +navigate to the corresponding editor. + +#### Scenario: Open the register + +- **WHEN** the user clicks the register deep-link +- **THEN** the widget navigates to the register in OpenRegister + +#### Scenario: Add a schema inline + +- **WHEN** the user clicks "+ Add schema" +- **THEN** the schemas widget opens the schema creation flow + +### Requirement: Application card tile surfaces status, version and role + +`ApplicationCard` SHALL bind the application (`app`, `appUuid`), expose the +production version and semver (`productionVersion`, `productionSemver`), expose +status and role labels (`statusKey`, `statusLabel`, `role`, `roleLabel`), +fall back gracefully on a broken icon (`onIconError`), and navigate to the +detail route on activation (`onCardActivate`). + +#### Scenario: Activate a card + +- **WHEN** the user activates an application card +- **THEN** the index navigates to that application's detail route + +### Requirement: Action bar and tabs drive publish, permissions, manifest, versions, icon + +`ApplicationDetailActions` SHALL gate and trigger publish +(`canPublish`, `publish`, `builderUrl`), resolve the available groups +(`availableGroups`), and handle a permissions save (`onPermissionsSave`). +`ApplicationManifestTab` SHALL parse, validate, and save the raw manifest +(`parseAndValidate`, `save`, `handler`). `ApplicationVersionsTab` SHALL handle +rollback and short-hex display (`onRollback`, `shortHex`). `ApplicationIconTab` +SHALL react to icon updates (`onIconUpdated`). `VirtualAppsActions` SHALL react +to wizard completion (`onWizardCreated`). + +#### Scenario: Publish a draft + +- **WHEN** the maintainer triggers publish and the gate is met +- **THEN** the action bar publishes the active version + +#### Scenario: Save an edited manifest + +- **WHEN** the maintainer edits the raw manifest and saves +- **THEN** the tab validates the JSON before persisting + +### Requirement: Manifest diff viewer and app shell + +`ManifestDiff` SHALL fetch both manifests (`fetch`, `from`, `to`, `slug`), +compute a deterministic diff (`diffParts`, `partClass`, `sortReplacer`, +`prettyManifest`), label each side (`fromLabel`, `toLabel`), and load on +`mounted`. `App.vue` SHALL expose the app shell context: icon, store URL, admin +flag, permissions, and the per-app translation helper (`appIcon`, +`appStoreUrl`, `isAdmin`, `permissions`, `translateForApp`, `created`). + +#### Scenario: Diff two versions + +- **WHEN** the user selects a from/to version pair +- **THEN** the viewer fetches both manifests and renders a stable diff + +#### Scenario: Resolve app context + +- **WHEN** the app shell is created +- **THEN** it resolves the admin flag, permissions, and translation helper diff --git a/openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md b/openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md new file mode 100644 index 00000000..67d5b9fe --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] task-1: application-detail-ui#REQ-OBADUI-001 — Detail header cockpit: versions, KPIs, activity, refresh (retroactive annotation) +- [x] task-2: application-detail-ui#REQ-OBADUI-002 — Overview widgets render rows with deep-links and inline actions (retroactive annotation) +- [x] task-3: application-detail-ui#REQ-OBADUI-003 — Application card tile surfaces status, version, and role (retroactive annotation) +- [x] task-4: application-detail-ui#REQ-OBADUI-004 — Action bar and tabs drive publish, permissions, manifest, versions, icon (retroactive annotation) +- [x] task-5: application-detail-ui#REQ-OBADUI-005 — Manifest diff viewer and app shell (retroactive annotation) diff --git a/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/design.md b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/design.md new file mode 100644 index 00000000..33355b21 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/design.md @@ -0,0 +1,11 @@ +# Design — creation-wizard-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The Create Application Wizard UI already ships; this records its observed +behaviour as numbered REQs so gate-16 spec-coverage can trace each method to a +requirement. No behaviour is changed. + +`CreateApplicationWizard` is the modal host that sequences four steps, gates +"next" on per-step validity, merges each step's slice into one payload, and +submits the atomic provisioning call. `IconUploadSection` is the shared +light/dark SVG upload control reused by the wizard and the icon tab. diff --git a/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/proposal.md b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/proposal.md new file mode 100644 index 00000000..452d6602 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/proposal.md @@ -0,0 +1,16 @@ +# Retrofit — creation-wizard-ui + +Describes observed behaviour of the Create Application Wizard UI — the +`CreateApplicationWizard` host and its four steps (`Step1Basics`, +`Step2Preset`, `Step3Custom`, `Step4Review`) plus the shared +`IconUploadSection` — as 4 new REQs. + +Code already exists (it implements the `application-creation-wizard` backend +capability). This change retroactively specifies the wizard's per-step +validation, navigation, payload merge, and icon-upload behaviour so gate-16 +spec-coverage can trace each method. + +## Approach +- Describe observed step validation, navigation gating, payload merge, and + submit per component. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/specs/creation-wizard-ui/spec.md b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/specs/creation-wizard-ui/spec.md new file mode 100644 index 00000000..4e550345 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/specs/creation-wizard-ui/spec.md @@ -0,0 +1,98 @@ +--- +retrofit: true +--- + +# creation-wizard-ui Specification + +## Purpose + +The Create Application Wizard is OpenBuilt's four-step modal that provisions the +full ADR-002 chain (Application + production ApplicationVersion + register + +schemas) in one atomic backend call. `CreateApplicationWizard` is the host; +`Step1Basics` captures identity, `Step2Preset` picks a starter, `Step3Custom` +authors schema rows, `Step4Review` previews. `IconUploadSection` is the shared +light/dark SVG upload control. + +This capability is observed behaviour of those components. It is the frontend +half of the `application-creation-wizard` backend capability. + +## ADDED Requirements + +### Requirement: Wizard host sequences steps, gates navigation, merges payload and submits + +`CreateApplicationWizard` SHALL track the current step (`displayStep`, +`visibleStepCount`), gate forward navigation on per-step validity +(`currentStepValid`, `allStepsValid`, `goNext`, `goBack`), merge each step's +slice into one payload (`mergePayload`), reset state on open +(`resetState`), manage modal visibility (`onModalShowUpdate`, `onClose`), and +submit the atomic provisioning call (`onSubmit`). + +#### Scenario: Block invalid step + +- **WHEN** the current step is invalid +- **THEN** the wizard disables "next" until the step validates + +#### Scenario: Submit the chain + +- **WHEN** all steps are valid and the user submits +- **THEN** the wizard sends the merged payload as one provisioning call + +### Requirement: Basics and preset steps validate identity and select a starter + +`Step1Basics` SHALL validate the name, slug, and description +(`isValid`, `onNameInput`, `onSlugInput`, `slugError`, `onDescriptionInput`, +`onIconChange`), deriving a slug error when the kebab pattern fails. +`Step2Preset` SHALL render the starter-preset options and record the selection +(`presetOptions`, `selectPreset`). + +#### Scenario: Reject a bad slug + +- **WHEN** the user enters a slug that violates the kebab pattern +- **THEN** the basics step surfaces a slug error and marks itself invalid + +#### Scenario: Choose a preset + +- **WHEN** the user selects a starter preset +- **THEN** the preset step records the selection for the payload + +### Requirement: Custom step authors the schema rows with reorder and slug validation + +`Step3Custom` SHALL let the user add/remove/reorder schema rows +(`addRow`, `removeRow`, `moveUp`, `moveDown`, `onDragStart`, `onDragOver`, +`onDrop`, `onDragEnd`), edit each row (`onNameInput`, `onSlugInput`, +`getSlugError`, `updateField` via `handler`), validate slug uniqueness and +correctness (`slugErrors`, `duplicateSlugs`, `isValid`), toggle advanced options +(`toggleAdvanced`), emit the rows upward (`emit`), and seed defaults on +`mounted`. + +#### Scenario: Reorder schema rows + +- **WHEN** the user drags a schema row to a new position +- **THEN** the row order updates and the emitted rows reflect it + +#### Scenario: Reject duplicate slugs + +- **WHEN** two schema rows share a slug +- **THEN** the custom step flags the duplicates and marks itself invalid + +### Requirement: Review step previews and icon upload validates SVGs + +`Step4Review` SHALL render the read-only summary: chain display, light/dark +icon previews, production slug, and version list (`chainDisplay`, +`iconLightUrl`, `iconDarkUrl`, `productionSlug`, `versions`). +`IconUploadSection` SHALL accept light/dark SVG files +(`onLightFileChange`, `onDarkFileChange`, `validateSvgFile`, `uploadIcon`), +preview them (`iconLightUrl`, `iconDarkUrl`, `onLightPreviewError`, +`onDarkPreviewError`), remove them (`removeIcon`, `removeLightIcon`, +`removeDarkIcon`), resolve the target object (`objectUuid`, `handler`), and +reject non-SVG uploads. + +#### Scenario: Reject a non-SVG upload + +- **WHEN** the user selects a non-SVG file +- **THEN** `validateSvgFile` rejects it and no upload occurs + +#### Scenario: Preview the chain + +- **WHEN** the user reaches the review step +- **THEN** the step renders the chain, icons, and version list read-only diff --git a/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md new file mode 100644 index 00000000..034133b6 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] task-1: creation-wizard-ui#REQ-OBCWUI-001 — Wizard host sequences steps, gates navigation, merges payload, submits (retroactive annotation) +- [x] task-2: creation-wizard-ui#REQ-OBCWUI-002 — Basics and preset steps validate identity and select a starter (retroactive annotation) +- [x] task-3: creation-wizard-ui#REQ-OBCWUI-003 — Custom step authors the schema rows with reorder and slug validation (retroactive annotation) +- [x] task-4: creation-wizard-ui#REQ-OBCWUI-004 — Review step previews and icon upload validates SVGs (retroactive annotation) diff --git a/openspec/changes/retrofit-2026-05-26-exporter-ui/design.md b/openspec/changes/retrofit-2026-05-26-exporter-ui/design.md new file mode 100644 index 00000000..5f3fc637 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-exporter-ui/design.md @@ -0,0 +1,10 @@ +# Design — exporter-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The exporter UI already ships; this records its observed behaviour as +numbered REQs so gate-16 spec-coverage can trace each method to a requirement. +No behaviour is changed. + +`ExportDialog` collects the export target, visibility, license, and version, +then submits the export job. `ExportJobsList` lists the queued/running/finished +jobs, polls for status, and labels each. diff --git a/openspec/changes/retrofit-2026-05-26-exporter-ui/proposal.md b/openspec/changes/retrofit-2026-05-26-exporter-ui/proposal.md new file mode 100644 index 00000000..65f9e92f --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-exporter-ui/proposal.md @@ -0,0 +1,14 @@ +# Retrofit — exporter-ui + +Describes observed behaviour of the exporter UI — the `ExportDialog` and the +`ExportJobsList` view — as 2 new REQs. + +Code already exists (it implements the `openbuilt-exporter` backend +capability). This change retroactively specifies the export-target/visibility/ +license selection, submission, and job polling behaviour so gate-16 +spec-coverage can trace each method. + +## Approach +- Describe observed option lists, submit, job list fetch/poll, and status + labelling. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-exporter-ui/specs/exporter-ui/spec.md b/openspec/changes/retrofit-2026-05-26-exporter-ui/specs/exporter-ui/spec.md new file mode 100644 index 00000000..2565bf54 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-exporter-ui/specs/exporter-ui/spec.md @@ -0,0 +1,45 @@ +--- +retrofit: true +--- + +# exporter-ui Specification + +## Purpose + +The exporter UI graduates a published OpenBuilt virtual app into a standalone +Nextcloud app. `ExportDialog` collects the export target, visibility, license, +and version, then submits the export job; `ExportJobsList` lists the jobs, polls +for status, and labels each. + +This capability is observed behaviour of those components. It is the frontend +half of the `openbuilt-exporter` backend capability. + +## ADDED Requirements + +### Requirement: Export dialog collects options and submits the job + +`ExportDialog` SHALL expose the target, visibility, license, and version option +lists (`targetOptions`, `visibilityOptions`, `licenseOptions`, +`versionOptions`), submit the export job (`submit`), and close (`onClose`). + +#### Scenario: Submit an export + +- **WHEN** the user selects a target, visibility, license, and version and submits +- **THEN** the dialog queues an export job with those options and closes + +### Requirement: Export jobs list fetches, polls and labels job status + +`ExportJobsList` SHALL fetch the export jobs (`fetchJobs`) on `mounted`, poll +for status updates, clean up the poll on `beforeDestroy`, label each job's +status (`statusLabel`), open the export dialog (`openDialog`), and react to a +newly queued job (`onQueued`). + +#### Scenario: Poll a running job + +- **WHEN** an export job is running +- **THEN** the list polls and updates its status label until the job finishes + +#### Scenario: Reflect a new job + +- **WHEN** a new export is queued from the dialog +- **THEN** the list adds the job and begins polling it diff --git a/openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md b/openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md new file mode 100644 index 00000000..0e488b99 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md @@ -0,0 +1,4 @@ +# Tasks + +- [x] task-1: exporter-ui#REQ-OBEXUI-001 — Export dialog collects options and submits the job (retroactive annotation) +- [x] task-2: exporter-ui#REQ-OBEXUI-002 — Export jobs list fetches, polls, and labels job status (retroactive annotation) diff --git a/openspec/changes/retrofit-2026-05-26-frontend-foundation/design.md b/openspec/changes/retrofit-2026-05-26-frontend-foundation/design.md new file mode 100644 index 00000000..f6f2c012 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-frontend-foundation/design.md @@ -0,0 +1,13 @@ +# Design — frontend-foundation (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The shared frontend foundation and the preferences endpoint already ship; +this records their observed behaviour as numbered REQs so gate-16 spec-coverage +can trace each method to a requirement. No behaviour is changed. + +The composables encapsulate cross-view logic (insights, live preview, manifest +validation, register picking, RBAC role resolution). The object/settings stores +hold the OR base URLs and app settings. The slug utilities enforce the shared +kebab pattern. The settings views read/write app-level config. The +`PreferencesController` is a generic per-user key/value endpoint backed by +Nextcloud IConfig user values, namespaced under `pref_` with a sanitised key. diff --git a/openspec/changes/retrofit-2026-05-26-frontend-foundation/proposal.md b/openspec/changes/retrofit-2026-05-26-frontend-foundation/proposal.md new file mode 100644 index 00000000..eb510bb9 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-frontend-foundation/proposal.md @@ -0,0 +1,19 @@ +# Retrofit — frontend-foundation + +Describes observed behaviour of OpenBuilt's shared frontend foundation and the +per-user preferences endpoint — the composables +(`useApplicationInsights`, `useLivePreview`, `useManifestValidator`, +`useRegisterPicker`, `useRole`), the Vuex/object stores +(`store/modules/object`, `store/modules/settings`, `store/store`), the slug +utilities (`utils/slugPattern`), the `PermissionsModal`, the settings views +(`settings/AdminRoot`, `settings/Settings`), and the backend +`PreferencesController` — as 5 new REQs. + +Code already exists. This change retroactively specifies the foundation +behaviour so gate-16 spec-coverage can trace each method. + +## Approach +- Describe observed composable contracts, store actions, slug validation, the + permissions modal sync/save, settings load/save, and the preferences + read/write endpoint. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-frontend-foundation/specs/frontend-foundation/spec.md b/openspec/changes/retrofit-2026-05-26-frontend-foundation/specs/frontend-foundation/spec.md new file mode 100644 index 00000000..3d6c4a0c --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-frontend-foundation/specs/frontend-foundation/spec.md @@ -0,0 +1,96 @@ +--- +retrofit: true +--- + +# frontend-foundation Specification + +## Purpose + +OpenBuilt's shared frontend foundation: cross-view composables (insights, live +preview, manifest validation, register picking, RBAC role resolution), the +object/settings Vuex stores, the slug utilities, the `PermissionsModal`, the +settings views, and the backend per-user `PreferencesController`. These pieces +underpin the higher-level designer, detail, wizard, and version-routing UIs. + +This capability is observed behaviour of those composables, stores, utilities, +modal, settings views, and the preferences endpoint. + +## ADDED Requirements + +### Requirement: Composables encapsulate insights, preview, validation and register-pick + +`useApplicationInsights` SHALL return reactive insights state for a version. +`useLivePreview` SHALL return the live-preview mount/state contract. +`useManifestValidator` SHALL return a manifest validation function and its +reactive error state. `useRegisterPicker(opts)` SHALL return the register +option list and selection contract. `useApplicationVersion` SHALL expose a +default editable version helper (`defaultEditableVersion`) alongside the active +version resolution. `useManifestHistory` SHALL load the version's manifest +history. + +#### Scenario: Validate a manifest + +- **WHEN** a caller invokes the validator composable's validate function +- **THEN** it returns the validation result and updates the reactive error state + +### Requirement: Role composable resolves per-app RBAC + +`useRole(application, userGroups)` SHALL resolve the caller's effective role +for an application; `getCurrentUserGroups()` SHALL read the current user's +groups; `hasAnyRole(application, userGroups)` SHALL return whether the caller +holds any of the application's roles. + +#### Scenario: Resolve no role + +- **WHEN** the caller belongs to no group mapped on the application +- **THEN** `hasAnyRole` returns false and `useRole` resolves the lowest role + +### Requirement: Object and settings stores hold OR base config + +The object store SHALL configure the OR base URLs (`configure`), register an +object type to its schema/register (`registerObjectType`), and fetch objects of +a type (`fetchObjects`). The settings store SHALL fetch and save app settings +(`fetchSettings`, `saveSettings`). The store root SHALL initialise the modules +(`initializeStores`). + +#### Scenario: Fetch objects of a registered type + +- **WHEN** a view fetches objects for a registered type +- **THEN** the store resolves the type's register/schema and returns the objects + +### Requirement: Slug utilities, permissions modal and settings views + +`utils/slugPattern` SHALL kebab-case an input (`toKebabCase`) and validate a +slug against the shared pattern (`validateSlug`). `PermissionsModal` SHALL sync +the editable permissions from the application (`syncFromApplication`), expose +the group options (`groupOptions`, `handler`), save them (`save`), and close +(`onClose`). The settings views (`settings/AdminRoot`, `settings/Settings`) +SHALL load on `created` and `Settings` SHALL persist on `save`. + +#### Scenario: Validate a slug + +- **WHEN** `validateSlug` receives a non-kebab string +- **THEN** it returns an invalid result + +#### Scenario: Save permissions + +- **WHEN** the user edits group roles and saves in the permissions modal +- **THEN** the modal emits the updated permissions and closes + +### Requirement: Per-user preferences endpoint reads and writes sanitised keys + +`PreferencesController` SHALL read a per-user preference (`getPreference`) and +write/clear one (`setPreference`), requiring an authenticated user (401 +otherwise), sanitising the key to a safe charset under the `pref_` namespace +(rejecting an empty sanitised key with 400), and clearing the value when an +empty string is written. Both endpoints SHALL return `{value: string|null}`. + +#### Scenario: Reject an unsafe key + +- **WHEN** a request supplies a key that sanitises to empty +- **THEN** the controller returns 400 without touching IConfig + +#### Scenario: Clear a preference + +- **WHEN** `setPreference` is called with an empty value +- **THEN** the controller deletes the stored user value and returns `{value: null}` diff --git a/openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md b/openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md new file mode 100644 index 00000000..4f95b33c --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] task-1: frontend-foundation#REQ-OBFFUI-001 — Composables encapsulate insights, preview, validation, register-pick (retroactive annotation) +- [x] task-2: frontend-foundation#REQ-OBFFUI-002 — Role composable resolves per-app RBAC (retroactive annotation) +- [x] task-3: frontend-foundation#REQ-OBFFUI-003 — Object and settings stores hold OR base config (retroactive annotation) +- [x] task-4: frontend-foundation#REQ-OBFFUI-004 — Slug utilities, permissions modal, settings views (retroactive annotation) +- [x] task-5: frontend-foundation#REQ-OBFFUI-005 — Per-user preferences endpoint reads and writes sanitised keys (retroactive annotation) diff --git a/openspec/changes/retrofit-2026-05-26-page-designer-ui/design.md b/openspec/changes/retrofit-2026-05-26-page-designer-ui/design.md new file mode 100644 index 00000000..153ca340 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-page-designer-ui/design.md @@ -0,0 +1,14 @@ +# Design — page-designer-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The Page Designer UI already ships; this records its observed behaviour as +numbered REQs so gate-16 spec-coverage can trace each method to a requirement. +No behaviour is changed. + +The Page Designer is a controlled component: `PageDesigner` takes a `manifest` +prop and emits `update:manifest` / `save-and-preview`; `PageDesignerHost` and +`BuilderHost` are the route-level hosts that resolve the slug + active version, +load the manifest from OpenRegister, and persist edits with a PUT. The nine +sub-editors each own one `page.type` and emit their config slice upward; the +field builders are reusable list editors (columns, actions, form fields, layout +items, widgets, sidebar tabs/sections) shared across sub-editors. diff --git a/openspec/changes/retrofit-2026-05-26-page-designer-ui/proposal.md b/openspec/changes/retrofit-2026-05-26-page-designer-ui/proposal.md new file mode 100644 index 00000000..5de91fec --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-page-designer-ui/proposal.md @@ -0,0 +1,16 @@ +# Retrofit — page-designer-ui + +Describes observed behaviour of the Page Designer UI layer — the +`PageDesigner` controlled view, its route-level hosts (`PageDesignerHost`, +`BuilderHost`), the nine per-page-type config sub-editors under +`src/components/page-editor/`, and the reusable field builders under +`src/components/page-editor/fields/` — as 5 new REQs. + +Code already exists (it implements the backend-facing `openbuilt-page-designer` +capability). This change retroactively specifies the frontend behaviour at the +component-method level so gate-16 spec-coverage can trace each method. + +## Approach +- Describe observed inputs, computed surfaces, emit contracts, undo/redo, and + validation wiring per component. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-page-designer-ui/specs/page-designer-ui/spec.md b/openspec/changes/retrofit-2026-05-26-page-designer-ui/specs/page-designer-ui/spec.md new file mode 100644 index 00000000..061c0207 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-page-designer-ui/specs/page-designer-ui/spec.md @@ -0,0 +1,150 @@ +--- +retrofit: true +--- + +# page-designer-ui Specification + +## Purpose + +The Page Designer is OpenBuilt's three-pane visual editor for an Application +version's manifest. The `PageDesigner` view is a controlled component (manifest +prop in, `update:manifest` / `save-and-preview` events out) that orchestrates a +page list, a menu tree, a per-page-type config sub-editor, undo/redo history, +and an inline validation surface. The route-level hosts (`PageDesignerHost`, +`BuilderHost`) resolve a slug + active version, load the manifest from +OpenRegister, and persist edits. Nine sub-editors under +`src/components/page-editor/` each own one `page.type`; reusable field builders +under `.../fields/` edit list-shaped config (columns, actions, form fields, +layout items, widgets, sidebar tabs/sections). + +This capability is observed behaviour of the `PageDesigner`, +`PageDesignerHost`, `BuilderHost`, the `page-editor/*` sub-editors, and the +`page-editor/fields/*` builders. It is the frontend half of the +`openbuilt-page-designer` backend capability. + +## ADDED Requirements + +### Requirement: Controlled designer orchestrates pages, menu, undo/redo and save + +The `PageDesigner` view SHALL expose the manifest's `pages` and `menu` as +computed surfaces, dispatch the centre pane to a sub-editor by page type +(`subEditorFor`, `selectedPage`, `selectPage`), maintain an undo/redo history +(`undo`, `redo`, `canUndo`, `canRedo`) over edits, emit `update:manifest` on +every page/menu/config change (`emitManifest`, `onPagesUpdate`, `onMenuUpdate`, +`onConfigUpdate`), gate and emit the save action (`saveAndPreview`, +`canSaveAndPreview`), and bind keyboard shortcuts (`onKeydown`). The `menu` and +`pages` accessors SHALL derive from the controlled manifest prop. + +#### Scenario: Edit a page's config + +- **WHEN** the active sub-editor emits a config slice +- **THEN** `PageDesigner` merges it into the manifest and emits `update:manifest` + +#### Scenario: Undo a change + +- **WHEN** the user triggers undo with available history +- **THEN** the manifest reverts to the previous snapshot and `canRedo` becomes true + +### Requirement: Route hosts resolve slug plus version and persist the manifest + +`PageDesignerHost` and `BuilderHost` SHALL resolve the route slug +(`routeSlug`, `slug`, `appId`, `appUuid`, `applicationUuid`) and the active +version from `?_version=` (`resolveVersion`, `versionSlug`), load the +Application + manifest on `created`/`load`, render a version-not-found state on +404 (`versionNotFound`), supply manifest options / placeholder manifest +(`manifestOptions`, `placeholderManifest`, `cacheKey`), receive +`onManifestUpdate`, and persist edits back to OpenRegister with a PUT (`save`). +A builder deep-link URL SHALL be derived (`builderUrl`). + +#### Scenario: Unknown version + +- **WHEN** the resolved `?_version=` slug is unknown or unauthorised +- **THEN** the host renders the version-not-found state instead of the designer + +#### Scenario: Persist edits + +- **WHEN** the designer emits an updated manifest +- **THEN** the host PUTs it to OpenRegister and reflects the saved state + +### Requirement: Per-page-type config sub-editors emit validated slices + +Each sub-editor SHALL bind its slice of `page.config`, emit an `update` +upward on edit, and expose a validated-key set. The sub-editors are +`IndexPageEditor`, `DetailPageEditor`, `FormPageEditor`, +`FilesPageEditor`, `DashboardPageEditor`, `ChatPageEditor`, `LogsPageEditor`, +`CustomPageEditor`, `SettingsPageEditor`, and `StubPageEditor`. Each editor +SHALL expose a +`validatedConfigKeys` set marking which keys passed validation. Register/schema +backed editors SHALL fetch their option lists (`fetchRegisters`, +`fetchSchemas`, `fetchSchemaProperties`) and the detail/index editors SHALL +manage the sidebar shape (`sidebarShape`, `setSidebarShape`, `updateSidebar`, +`updateSidebarKey`, `onSidebarToggle`, `sidebarEnabled`). Form/log/chat editors +SHALL manage their transport/submit/source shape (`submitShape`, `sourceShape`, +`transportShape`, `setSubmitHandler`, `setSubmitEndpoint`, `setSourceShape`, +`setTransport`). Editors with a setup composable SHALL wire it in `setup`. + +#### Scenario: Edit a config field + +- **WHEN** the user edits a config field in a sub-editor +- **THEN** the editor emits the updated config slice and recomputes + `validatedConfigKeys` + +#### Scenario: Populate register/schema pickers + +- **WHEN** a register/schema-backed sub-editor mounts +- **THEN** it fetches the register, schema, and property option lists + +### Requirement: Reusable field builders edit list-shaped config + +Each field builder SHALL expose a local working copy of its list, support +add/remove/reorder of rows, edit per-row fields, and emit the updated list +upward. The field builders are `ColumnBuilder`, `ActionBuilder`, +`FormFieldBuilder`, `LayoutItemBuilder`, `WidgetBuilder`, `SidebarTabBuilder`, +`SidebarSectionBuilder`, and `SettingsSectionBuilder`; the menu/page list +editors are `MenuTreeEditor` and `PageListEditor`. Each builder SHALL hold a +local working copy of its list (`localColumns`, `localActions`, `localFields`, +`localItems`, `localWidgets`, `localTabs`, `localSections`), support +add/remove/reorder of rows (`addColumn`/`removeColumn`, +`addAction`/`removeAction`, `addField`/`removeField`, `addItem`/`removeItem`, +`addWidget`/`removeWidget`, `addTab`/`removeTab`, `addSection`/`removeSection`, +`addChild`/`removeChild`/`addEntry`/`removeEntry`, `moveUp`/`moveDown`, +`onReorder`/`onTopLevelReorder`/`onChildrenReorder`), edit per-row fields +(`updateField`, `updateChildField`, `updateActionField`, `updateTabField`, +`updateColumns`, `updateWidget`, `onKeyChange`, `onLabelInput`, `updateNum`), +and emit the updated list upward (`emit`). Row-render helpers (`rowKey`, +`rowLabel`, `schemaPropertyKeys`, `bodyKind`, `setBodyKind`, `stringifyProps`, +`onPropsInput`, `onWidgetPropsInput`) SHALL shape per-row display. The page-list +editor SHALL validate uniqueness and route patterns (`duplicateIds`, +`invalidRoutes`, `hasError`, `confirmAdd`, `startAdd`, `cancelAdd`). + +#### Scenario: Add and reorder a row + +- **WHEN** the user adds a row and moves it up or down +- **THEN** the builder updates its local list and emits the reordered list + +#### Scenario: Reject duplicate page ids + +- **WHEN** the user adds a page whose id duplicates an existing one +- **THEN** the page-list editor flags the error and blocks confirmation + +### Requirement: Inline validation surface and config-field registration + +`PageDesigner` SHALL provide a `pageEditorValidator` to descendant sub-editors +(`provide`), let fields register/unregister for validation +(`registerConfigField`, `unregisterConfigField`, `configPathPrefix`, +`configErrorFor`), aggregate validator errors for the right-hand panel +(`validatorErrors`), enforce menu-nesting depth limits (`onDepthViolation`), +and wire its reactive state in `setup`. The `CustomPageEditor` SHALL expose the +customComponents registry keys (`registryKeys`, `otherKeys`, +`validatedConfigKeys`) and stringify free-form props (`stringifyProps`, +`onPropsInput`, `handler`). + +#### Scenario: Inline validation mark + +- **WHEN** a registered config field fails validation +- **THEN** the field shows its error and it is aggregated into `validatorErrors` + +#### Scenario: Depth-limit guard + +- **WHEN** a menu edit would exceed the two-level nesting limit +- **THEN** the designer rejects it via `onDepthViolation` diff --git a/openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md b/openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md new file mode 100644 index 00000000..08049744 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] task-1: page-designer-ui#REQ-OBPDUI-001 — Controlled designer orchestrates pages, menu, undo/redo, save (retroactive annotation) +- [x] task-2: page-designer-ui#REQ-OBPDUI-002 — Route hosts resolve slug + version and persist the manifest (retroactive annotation) +- [x] task-3: page-designer-ui#REQ-OBPDUI-003 — Per-page-type config sub-editors emit validated slices (retroactive annotation) +- [x] task-4: page-designer-ui#REQ-OBPDUI-004 — Reusable field builders edit list-shaped config (retroactive annotation) +- [x] task-5: page-designer-ui#REQ-OBPDUI-005 — Inline validation surface and config-field registration (retroactive annotation) diff --git a/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/design.md b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/design.md new file mode 100644 index 00000000..0944fdb0 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/design.md @@ -0,0 +1,10 @@ +# Design — template-catalogue-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The template gallery UI already ships; this records its observed +behaviour as numbered REQs so gate-16 spec-coverage can trace each method to a +requirement. No behaviour is changed. + +`TemplateGallery` fetches `ApplicationTemplate` records, filters by category, +resolves per-template screenshots, and opens the clone dialog. +`CloneTemplateDialog` submits the clone and redirects to the new application. diff --git a/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/proposal.md b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/proposal.md new file mode 100644 index 00000000..0eac914d --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/proposal.md @@ -0,0 +1,14 @@ +# Retrofit — template-catalogue-ui + +Describes observed behaviour of the starter-template gallery UI — the +`TemplateGallery` view and the `CloneTemplateDialog` modal — as 2 new REQs. + +Code already exists (it implements the `openbuilt-template-catalogue` backend +capability). This change retroactively specifies the gallery filtering, clone +submission, and post-clone redirect behaviour so gate-16 spec-coverage can trace +each method. + +## Approach +- Describe observed template fetch/filter, category options, screenshot + resolution, and clone submit/redirect. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/specs/template-catalogue-ui/spec.md b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/specs/template-catalogue-ui/spec.md new file mode 100644 index 00000000..681bec1b --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/specs/template-catalogue-ui/spec.md @@ -0,0 +1,54 @@ +--- +retrofit: true +--- + +# template-catalogue-ui Specification + +## Purpose + +The template-catalogue UI is OpenBuilt's starter-template gallery. +`TemplateGallery` fetches `ApplicationTemplate` records, filters by category, +resolves per-template screenshots, and opens the clone dialog; +`CloneTemplateDialog` validates the clone target, submits, and redirects to the +new application. + +This capability is observed behaviour of those components. It is the frontend +half of the `openbuilt-template-catalogue` backend capability. + +## ADDED Requirements + +### Requirement: Gallery fetches, filters and resolves template screenshots + +`TemplateGallery` SHALL fetch the available templates (`fetchTemplates`), +expose category filter options and the current filtered set +(`categoryOptions`, `categoryLabel`, `filteredTemplates`), resolve each +template's screenshot with a fallback (`resolveScreenshot`), open the clone +modal (`openClone`), and redirect after a successful clone +(`onCloneSubmit`, `redirectAfterClone`). + +#### Scenario: Filter by category + +- **WHEN** the user selects a category +- **THEN** the gallery narrows the visible templates to that category + +#### Scenario: Open clone + +- **WHEN** the user clicks "Use this template" +- **THEN** the gallery opens the clone dialog seeded with that template + +### Requirement: Clone dialog validates, submits and redirects + +`CloneTemplateDialog` SHALL open seeded from a template (`open`, +`resolvedTitle`), gate submission on a valid target (`canSubmit`), submit the +clone (`submit`), surface errors (`setError`), and close (`onClose`). On success +the gallery SHALL redirect to the new application. + +#### Scenario: Reject an empty target + +- **WHEN** the clone target is incomplete +- **THEN** `canSubmit` is false and submission is blocked + +#### Scenario: Redirect after clone + +- **WHEN** the clone succeeds +- **THEN** the dialog closes and the gallery redirects to the new application diff --git a/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md new file mode 100644 index 00000000..527ac8e3 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md @@ -0,0 +1,4 @@ +# Tasks + +- [x] task-1: template-catalogue-ui#REQ-OBTCUI-001 — Gallery fetches, filters, and resolves template screenshots (retroactive annotation) +- [x] task-2: template-catalogue-ui#REQ-OBTCUI-002 — Clone dialog validates, submits, and redirects (retroactive annotation) diff --git a/openspec/changes/retrofit-2026-05-26-version-routing-ui/design.md b/openspec/changes/retrofit-2026-05-26-version-routing-ui/design.md new file mode 100644 index 00000000..252172dd --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-version-routing-ui/design.md @@ -0,0 +1,11 @@ +# Design — version-routing-ui (retrofit) + +Retrofit change. Tasks describe retroactive annotation, not new implementation +work. The version-history / promotion / rollback UI already ships; this records +its observed behaviour as numbered REQs so gate-16 spec-coverage can trace each +method to a requirement. No behaviour is changed. + +`VersionHistory` lists OR object-time-travel snapshots and offers compare + +rollback. `PromoteVersionDialog` computes a default promotion strategy and gates +the destructive confirm. `RollbackConfirmModal` is the rollback gate. The +composables resolve the active version and load the manifest history. diff --git a/openspec/changes/retrofit-2026-05-26-version-routing-ui/proposal.md b/openspec/changes/retrofit-2026-05-26-version-routing-ui/proposal.md new file mode 100644 index 00000000..8be36f40 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-version-routing-ui/proposal.md @@ -0,0 +1,17 @@ +# Retrofit — version-routing-ui + +Describes observed behaviour of the version-history / promotion / rollback UI — +the `VersionHistory` view, the `PromoteVersionDialog`, the +`RollbackConfirmModal`, and the version composables +(`useApplicationVersion`, `useManifestHistory`, `useApplicationVersion`'s +default helper) — as 4 new REQs. + +Code already exists (it implements the `version-routing`, `version-promotion`, +and `openbuilt-version-snapshots` backend capabilities). This change +retroactively specifies the frontend behaviour so gate-16 spec-coverage can +trace each method. + +## Approach +- Describe observed time-travel listing, compare/rollback gating, promotion + strategy computation, and version resolution per component/composable. +- REQs match behaviour, not aspiration. No behaviour is changed. diff --git a/openspec/changes/retrofit-2026-05-26-version-routing-ui/specs/version-routing-ui/spec.md b/openspec/changes/retrofit-2026-05-26-version-routing-ui/specs/version-routing-ui/spec.md new file mode 100644 index 00000000..c8f7f97c --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-version-routing-ui/specs/version-routing-ui/spec.md @@ -0,0 +1,78 @@ +--- +retrofit: true +--- + +# version-routing-ui Specification + +## Purpose + +The version-routing UI exposes OpenBuilt's two-object version model to the +maintainer: `VersionHistory` lists OR object-time-travel snapshots with compare ++ rollback, `PromoteVersionDialog` moves a manifest/schema/data set to a +downstream version with a computed default strategy and a destructive-confirm +gate, `RollbackConfirmModal` gates the revert, and the version composables +(`useApplicationVersion`, `useManifestHistory`) resolve the active version and +load history. + +This capability is observed behaviour of those components. It is the frontend +half of the `version-routing`, `version-promotion`, and +`openbuilt-version-snapshots` backend capabilities. + +## ADDED Requirements + +### Requirement: Version history lists snapshots and gates compare and rollback + +`VersionHistory` SHALL load the time-travel snapshot rows (`refresh`, +`handler`), expose per-row display accessors (`rowKey`, `rowUuid`, +`rowVersion`, `rowNotes`, `rowPublishedAt`, `rowPublishedBy`, +`rowApplicationUuid`, `formatDate`), let the user compare two snapshots +(`compare`), and route rollback through a confirm flow +(`askRollback`, `onRollbackConfirmed`, `onRollbackCancelled`). + +#### Scenario: List snapshots + +- **WHEN** the version-history view loads +- **THEN** it renders one row per time-travel snapshot with author and timestamp + +#### Scenario: Rollback through confirm + +- **WHEN** the user requests a rollback +- **THEN** a confirm flow gates the revert until confirmed + +### Requirement: Promote dialog computes strategy and gates the destructive confirm + +`PromoteVersionDialog` SHALL bind the application and target version +(`application`, `targetVersion`), compute a default promotion strategy +(`computeDefaultStrategy`), render the summary and confirm helper text +(`summaryText`, `confirmHelperText`, `confirmInputLabel`), enforce a +destructive-confirm gate (`isDestructiveGateMet`), and emit confirm/cancel +(`onConfirm`, `onCancel`). + +#### Scenario: Gate a destructive promotion + +- **WHEN** the promotion would overwrite downstream data +- **THEN** the dialog requires the typed confirmation before `onConfirm` fires + +### Requirement: Rollback confirm modal gates the revert + +`RollbackConfirmModal` SHALL render the target title and formatted publish time +(`title`, `formattedPublishedAt`), track open state (`onUpdateOpen`), and emit +confirm/cancel (`confirm`, `cancel`). + +#### Scenario: Confirm a rollback + +- **WHEN** the user confirms in the rollback modal +- **THEN** the modal emits the confirm event with the target snapshot + +### Requirement: Version composables resolve active version and manifest history + +`useApplicationVersion(appSlug, versionSlug)` SHALL resolve the active version, +exposing a default editable version helper (`defaultEditableVersion`). +`useManifestHistory` SHALL load the manifest history for a version. These +composables SHALL return reactive state consumed by the builder hosts and the +detail header. + +#### Scenario: Resolve the active version + +- **WHEN** a builder host invokes `useApplicationVersion` with a slug pair +- **THEN** the composable resolves the matching version or signals not-found diff --git a/openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md b/openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md new file mode 100644 index 00000000..6f8e8e9f --- /dev/null +++ b/openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] task-1: version-routing-ui#REQ-OBVRUI-001 — Version history lists snapshots and gates compare/rollback (retroactive annotation) +- [x] task-2: version-routing-ui#REQ-OBVRUI-002 — Promote dialog computes strategy and gates the destructive confirm (retroactive annotation) +- [x] task-3: version-routing-ui#REQ-OBVRUI-003 — Rollback confirm modal gates the revert (retroactive annotation) +- [x] task-4: version-routing-ui#REQ-OBVRUI-004 — Version composables resolve active version and manifest history (retroactive annotation) diff --git a/openspec/specs/application-detail-ui/spec.md b/openspec/specs/application-detail-ui/spec.md new file mode 100644 index 00000000..831449db --- /dev/null +++ b/openspec/specs/application-detail-ui/spec.md @@ -0,0 +1,122 @@ +--- +retrofit: true +--- + +# application-detail-ui Specification + +## Purpose + +The Application detail UI is OpenBuilt's maintainer cockpit. The +`ApplicationDetailHeader` (registered as the detail-route `headerComponent`) +renders version pills, a four-card KPI grid, an activity sparkline, and the +stacked overview widgets (register / schemas / groups / pages / menu). The +`ApplicationCard` is the grid tile on the index. The action bar +(`ApplicationDetailActions`), tabs (`tabs/*`), and `VirtualAppsActions` drive +publish, permissions, manifest, versions, and icon actions. `ManifestDiff` +renders a version-to-version manifest diff, and `App.vue` is the app shell. + +This capability is observed behaviour of those components. It is the frontend +half of the `application-detail-overview` and `application-insights` backend +capabilities. + +## Requirements + +### Requirement: Detail header cockpit renders versions, KPIs, activity and refresh + +`ApplicationDetailHeader` SHALL bind the application object +(`object`, `objectId`, `appSlug`, `applicationName`, `applicationDescription`, +`applicationStatus`, `iconUrl`, `banner`), resolve and order the version chain +(`loadVersions`, `orderedVersions`, `visibleVersions`, `activeVersion`, +`activeVersionSlug`, `activeVersionUuid`, `selectVersion`, `selectedWindow`, +`windowOptions`), surface the active version's manifest-derived counts +(`activeManifest`, `activeMenu`, `activePages`, `activeSchemas`, `schemaCount`), +fetch and debounce insights (`fetchInsights`, `scheduleInsightsFetch`, +`sparklinePoints`, `totalActivityEvents`, `filesTooltip`), expose the production +pointer (`productionVersion`, `productionSemver`, `productionVersionUuid`, +`switchToProduction`), open permissions/promote flows (`onOpenPermissions`, +`onPromoteClick`, `callerRole`), refresh on demand (`refreshApplication`), and +clean up timers on `beforeDestroy`/`mounted`. + +#### Scenario: Select a version pill + +- **WHEN** the user activates a non-production version pill +- **THEN** the header switches the active version and re-derives the manifest + counts and insights window + +#### Scenario: Refresh insights window + +- **WHEN** the user changes the time window +- **THEN** the header debounces and re-fetches the version-scoped insights + +### Requirement: Overview widgets render rows with deep-links and inline actions + +The overview widgets SHALL each render their domain rows: `RegisterWidget` +read-only with an "Open in OpenRegister" deep-link (`registerSlug`, +`openInOpenRegister`); `SchemasWidget` with deep-link, count formatting, and an +inline add (`openSchema`, `addSchema`, `formatCount`); `GroupsWidget` with role +badges (`rows`, `roleLabel`, `memberLabel`, `openEditor`); `PagesWidget` +(`openPage`); and `MenuWidget` (`openEntry`). Each open/add action SHALL emit or +navigate to the corresponding editor. + +#### Scenario: Open the register + +- **WHEN** the user clicks the register deep-link +- **THEN** the widget navigates to the register in OpenRegister + +#### Scenario: Add a schema inline + +- **WHEN** the user clicks "+ Add schema" +- **THEN** the schemas widget opens the schema creation flow + +### Requirement: Application card tile surfaces status, version and role + +`ApplicationCard` SHALL bind the application (`app`, `appUuid`), expose the +production version and semver (`productionVersion`, `productionSemver`), expose +status and role labels (`statusKey`, `statusLabel`, `role`, `roleLabel`), +fall back gracefully on a broken icon (`onIconError`), and navigate to the +detail route on activation (`onCardActivate`). + +#### Scenario: Activate a card + +- **WHEN** the user activates an application card +- **THEN** the index navigates to that application's detail route + +### Requirement: Action bar and tabs drive publish, permissions, manifest, versions, icon + +`ApplicationDetailActions` SHALL gate and trigger publish +(`canPublish`, `publish`, `builderUrl`), resolve the available groups +(`availableGroups`), and handle a permissions save (`onPermissionsSave`). +`ApplicationManifestTab` SHALL parse, validate, and save the raw manifest +(`parseAndValidate`, `save`, `handler`). `ApplicationVersionsTab` SHALL handle +rollback and short-hex display (`onRollback`, `shortHex`). `ApplicationIconTab` +SHALL react to icon updates (`onIconUpdated`). `VirtualAppsActions` SHALL react +to wizard completion (`onWizardCreated`). + +#### Scenario: Publish a draft + +- **WHEN** the maintainer triggers publish and the gate is met +- **THEN** the action bar publishes the active version + +#### Scenario: Save an edited manifest + +- **WHEN** the maintainer edits the raw manifest and saves +- **THEN** the tab validates the JSON before persisting + +### Requirement: Manifest diff viewer and app shell + +`ManifestDiff` SHALL fetch both manifests (`fetch`, `from`, `to`, `slug`), +compute a deterministic diff (`diffParts`, `partClass`, `sortReplacer`, +`prettyManifest`), label each side (`fromLabel`, `toLabel`), and load on +`mounted`. `App.vue` SHALL expose the app shell context: icon, store URL, admin +flag, permissions, and the per-app translation helper (`appIcon`, +`appStoreUrl`, `isAdmin`, `permissions`, `translateForApp`, `created`). + +#### Scenario: Diff two versions + +- **WHEN** the user selects a from/to version pair +- **THEN** the viewer fetches both manifests and renders a stable diff + +#### Scenario: Resolve app context + +- **WHEN** the app shell is created +- **THEN** it resolves the admin flag, permissions, and translation helper diff --git a/openspec/specs/creation-wizard-ui/spec.md b/openspec/specs/creation-wizard-ui/spec.md new file mode 100644 index 00000000..7d0c005d --- /dev/null +++ b/openspec/specs/creation-wizard-ui/spec.md @@ -0,0 +1,98 @@ +--- +retrofit: true +--- + +# creation-wizard-ui Specification + +## Purpose + +The Create Application Wizard is OpenBuilt's four-step modal that provisions the +full ADR-002 chain (Application + production ApplicationVersion + register + +schemas) in one atomic backend call. `CreateApplicationWizard` is the host; +`Step1Basics` captures identity, `Step2Preset` picks a starter, `Step3Custom` +authors schema rows, `Step4Review` previews. `IconUploadSection` is the shared +light/dark SVG upload control. + +This capability is observed behaviour of those components. It is the frontend +half of the `application-creation-wizard` backend capability. + +## Requirements + +### Requirement: Wizard host sequences steps, gates navigation, merges payload and submits + +`CreateApplicationWizard` SHALL track the current step (`displayStep`, +`visibleStepCount`), gate forward navigation on per-step validity +(`currentStepValid`, `allStepsValid`, `goNext`, `goBack`), merge each step's +slice into one payload (`mergePayload`), reset state on open +(`resetState`), manage modal visibility (`onModalShowUpdate`, `onClose`), and +submit the atomic provisioning call (`onSubmit`). + +#### Scenario: Block invalid step + +- **WHEN** the current step is invalid +- **THEN** the wizard disables "next" until the step validates + +#### Scenario: Submit the chain + +- **WHEN** all steps are valid and the user submits +- **THEN** the wizard sends the merged payload as one provisioning call + +### Requirement: Basics and preset steps validate identity and select a starter + +`Step1Basics` SHALL validate the name, slug, and description +(`isValid`, `onNameInput`, `onSlugInput`, `slugError`, `onDescriptionInput`, +`onIconChange`), deriving a slug error when the kebab pattern fails. +`Step2Preset` SHALL render the starter-preset options and record the selection +(`presetOptions`, `selectPreset`). + +#### Scenario: Reject a bad slug + +- **WHEN** the user enters a slug that violates the kebab pattern +- **THEN** the basics step surfaces a slug error and marks itself invalid + +#### Scenario: Choose a preset + +- **WHEN** the user selects a starter preset +- **THEN** the preset step records the selection for the payload + +### Requirement: Custom step authors the schema rows with reorder and slug validation + +`Step3Custom` SHALL let the user add/remove/reorder schema rows +(`addRow`, `removeRow`, `moveUp`, `moveDown`, `onDragStart`, `onDragOver`, +`onDrop`, `onDragEnd`), edit each row (`onNameInput`, `onSlugInput`, +`getSlugError`, `updateField` via `handler`), validate slug uniqueness and +correctness (`slugErrors`, `duplicateSlugs`, `isValid`), toggle advanced options +(`toggleAdvanced`), emit the rows upward (`emit`), and seed defaults on +`mounted`. + +#### Scenario: Reorder schema rows + +- **WHEN** the user drags a schema row to a new position +- **THEN** the row order updates and the emitted rows reflect it + +#### Scenario: Reject duplicate slugs + +- **WHEN** two schema rows share a slug +- **THEN** the custom step flags the duplicates and marks itself invalid + +### Requirement: Review step previews and icon upload validates SVGs + +`Step4Review` SHALL render the read-only summary: chain display, light/dark +icon previews, production slug, and version list (`chainDisplay`, +`iconLightUrl`, `iconDarkUrl`, `productionSlug`, `versions`). +`IconUploadSection` SHALL accept light/dark SVG files +(`onLightFileChange`, `onDarkFileChange`, `validateSvgFile`, `uploadIcon`), +preview them (`iconLightUrl`, `iconDarkUrl`, `onLightPreviewError`, +`onDarkPreviewError`), remove them (`removeIcon`, `removeLightIcon`, +`removeDarkIcon`), resolve the target object (`objectUuid`, `handler`), and +reject non-SVG uploads. + +#### Scenario: Reject a non-SVG upload + +- **WHEN** the user selects a non-SVG file +- **THEN** `validateSvgFile` rejects it and no upload occurs + +#### Scenario: Preview the chain + +- **WHEN** the user reaches the review step +- **THEN** the step renders the chain, icons, and version list read-only diff --git a/openspec/specs/exporter-ui/spec.md b/openspec/specs/exporter-ui/spec.md new file mode 100644 index 00000000..e706a8a6 --- /dev/null +++ b/openspec/specs/exporter-ui/spec.md @@ -0,0 +1,45 @@ +--- +retrofit: true +--- + +# exporter-ui Specification + +## Purpose + +The exporter UI graduates a published OpenBuilt virtual app into a standalone +Nextcloud app. `ExportDialog` collects the export target, visibility, license, +and version, then submits the export job; `ExportJobsList` lists the jobs, polls +for status, and labels each. + +This capability is observed behaviour of those components. It is the frontend +half of the `openbuilt-exporter` backend capability. + +## Requirements + +### Requirement: Export dialog collects options and submits the job + +`ExportDialog` SHALL expose the target, visibility, license, and version option +lists (`targetOptions`, `visibilityOptions`, `licenseOptions`, +`versionOptions`), submit the export job (`submit`), and close (`onClose`). + +#### Scenario: Submit an export + +- **WHEN** the user selects a target, visibility, license, and version and submits +- **THEN** the dialog queues an export job with those options and closes + +### Requirement: Export jobs list fetches, polls and labels job status + +`ExportJobsList` SHALL fetch the export jobs (`fetchJobs`) on `mounted`, poll +for status updates, clean up the poll on `beforeDestroy`, label each job's +status (`statusLabel`), open the export dialog (`openDialog`), and react to a +newly queued job (`onQueued`). + +#### Scenario: Poll a running job + +- **WHEN** an export job is running +- **THEN** the list polls and updates its status label until the job finishes + +#### Scenario: Reflect a new job + +- **WHEN** a new export is queued from the dialog +- **THEN** the list adds the job and begins polling it diff --git a/openspec/specs/frontend-foundation/spec.md b/openspec/specs/frontend-foundation/spec.md new file mode 100644 index 00000000..927c22a5 --- /dev/null +++ b/openspec/specs/frontend-foundation/spec.md @@ -0,0 +1,96 @@ +--- +retrofit: true +--- + +# frontend-foundation Specification + +## Purpose + +OpenBuilt's shared frontend foundation: cross-view composables (insights, live +preview, manifest validation, register picking, RBAC role resolution), the +object/settings Vuex stores, the slug utilities, the `PermissionsModal`, the +settings views, and the backend per-user `PreferencesController`. These pieces +underpin the higher-level designer, detail, wizard, and version-routing UIs. + +This capability is observed behaviour of those composables, stores, utilities, +modal, settings views, and the preferences endpoint. + +## Requirements + +### Requirement: Composables encapsulate insights, preview, validation and register-pick + +`useApplicationInsights` SHALL return reactive insights state for a version. +`useLivePreview` SHALL return the live-preview mount/state contract. +`useManifestValidator` SHALL return a manifest validation function and its +reactive error state. `useRegisterPicker(opts)` SHALL return the register +option list and selection contract. `useApplicationVersion` SHALL expose a +default editable version helper (`defaultEditableVersion`) alongside the active +version resolution. `useManifestHistory` SHALL load the version's manifest +history. + +#### Scenario: Validate a manifest + +- **WHEN** a caller invokes the validator composable's validate function +- **THEN** it returns the validation result and updates the reactive error state + +### Requirement: Role composable resolves per-app RBAC + +`useRole(application, userGroups)` SHALL resolve the caller's effective role +for an application; `getCurrentUserGroups()` SHALL read the current user's +groups; `hasAnyRole(application, userGroups)` SHALL return whether the caller +holds any of the application's roles. + +#### Scenario: Resolve no role + +- **WHEN** the caller belongs to no group mapped on the application +- **THEN** `hasAnyRole` returns false and `useRole` resolves the lowest role + +### Requirement: Object and settings stores hold OR base config + +The object store SHALL configure the OR base URLs (`configure`), register an +object type to its schema/register (`registerObjectType`), and fetch objects of +a type (`fetchObjects`). The settings store SHALL fetch and save app settings +(`fetchSettings`, `saveSettings`). The store root SHALL initialise the modules +(`initializeStores`). + +#### Scenario: Fetch objects of a registered type + +- **WHEN** a view fetches objects for a registered type +- **THEN** the store resolves the type's register/schema and returns the objects + +### Requirement: Slug utilities, permissions modal and settings views + +`utils/slugPattern` SHALL kebab-case an input (`toKebabCase`) and validate a +slug against the shared pattern (`validateSlug`). `PermissionsModal` SHALL sync +the editable permissions from the application (`syncFromApplication`), expose +the group options (`groupOptions`, `handler`), save them (`save`), and close +(`onClose`). The settings views (`settings/AdminRoot`, `settings/Settings`) +SHALL load on `created` and `Settings` SHALL persist on `save`. + +#### Scenario: Validate a slug + +- **WHEN** `validateSlug` receives a non-kebab string +- **THEN** it returns an invalid result + +#### Scenario: Save permissions + +- **WHEN** the user edits group roles and saves in the permissions modal +- **THEN** the modal emits the updated permissions and closes + +### Requirement: Per-user preferences endpoint reads and writes sanitised keys + +`PreferencesController` SHALL read a per-user preference (`getPreference`) and +write/clear one (`setPreference`), requiring an authenticated user (401 +otherwise), sanitising the key to a safe charset under the `pref_` namespace +(rejecting an empty sanitised key with 400), and clearing the value when an +empty string is written. Both endpoints SHALL return `{value: string|null}`. + +#### Scenario: Reject an unsafe key + +- **WHEN** a request supplies a key that sanitises to empty +- **THEN** the controller returns 400 without touching IConfig + +#### Scenario: Clear a preference + +- **WHEN** `setPreference` is called with an empty value +- **THEN** the controller deletes the stored user value and returns `{value: null}` diff --git a/openspec/specs/page-designer-ui/spec.md b/openspec/specs/page-designer-ui/spec.md new file mode 100644 index 00000000..200c218e --- /dev/null +++ b/openspec/specs/page-designer-ui/spec.md @@ -0,0 +1,150 @@ +--- +retrofit: true +--- + +# page-designer-ui Specification + +## Purpose + +The Page Designer is OpenBuilt's three-pane visual editor for an Application +version's manifest. The `PageDesigner` view is a controlled component (manifest +prop in, `update:manifest` / `save-and-preview` events out) that orchestrates a +page list, a menu tree, a per-page-type config sub-editor, undo/redo history, +and an inline validation surface. The route-level hosts (`PageDesignerHost`, +`BuilderHost`) resolve a slug + active version, load the manifest from +OpenRegister, and persist edits. Nine sub-editors under +`src/components/page-editor/` each own one `page.type`; reusable field builders +under `.../fields/` edit list-shaped config (columns, actions, form fields, +layout items, widgets, sidebar tabs/sections). + +This capability is observed behaviour of the `PageDesigner`, +`PageDesignerHost`, `BuilderHost`, the `page-editor/*` sub-editors, and the +`page-editor/fields/*` builders. It is the frontend half of the +`openbuilt-page-designer` backend capability. + +## Requirements + +### Requirement: Controlled designer orchestrates pages, menu, undo/redo and save + +The `PageDesigner` view SHALL expose the manifest's `pages` and `menu` as +computed surfaces, dispatch the centre pane to a sub-editor by page type +(`subEditorFor`, `selectedPage`, `selectPage`), maintain an undo/redo history +(`undo`, `redo`, `canUndo`, `canRedo`) over edits, emit `update:manifest` on +every page/menu/config change (`emitManifest`, `onPagesUpdate`, `onMenuUpdate`, +`onConfigUpdate`), gate and emit the save action (`saveAndPreview`, +`canSaveAndPreview`), and bind keyboard shortcuts (`onKeydown`). The `menu` and +`pages` accessors SHALL derive from the controlled manifest prop. + +#### Scenario: Edit a page's config + +- **WHEN** the active sub-editor emits a config slice +- **THEN** `PageDesigner` merges it into the manifest and emits `update:manifest` + +#### Scenario: Undo a change + +- **WHEN** the user triggers undo with available history +- **THEN** the manifest reverts to the previous snapshot and `canRedo` becomes true + +### Requirement: Route hosts resolve slug plus version and persist the manifest + +`PageDesignerHost` and `BuilderHost` SHALL resolve the route slug +(`routeSlug`, `slug`, `appId`, `appUuid`, `applicationUuid`) and the active +version from `?_version=` (`resolveVersion`, `versionSlug`), load the +Application + manifest on `created`/`load`, render a version-not-found state on +404 (`versionNotFound`), supply manifest options / placeholder manifest +(`manifestOptions`, `placeholderManifest`, `cacheKey`), receive +`onManifestUpdate`, and persist edits back to OpenRegister with a PUT (`save`). +A builder deep-link URL SHALL be derived (`builderUrl`). + +#### Scenario: Unknown version + +- **WHEN** the resolved `?_version=` slug is unknown or unauthorised +- **THEN** the host renders the version-not-found state instead of the designer + +#### Scenario: Persist edits + +- **WHEN** the designer emits an updated manifest +- **THEN** the host PUTs it to OpenRegister and reflects the saved state + +### Requirement: Per-page-type config sub-editors emit validated slices + +Each sub-editor SHALL bind its slice of `page.config`, emit an `update` +upward on edit, and expose a validated-key set. The sub-editors are +`IndexPageEditor`, `DetailPageEditor`, `FormPageEditor`, +`FilesPageEditor`, `DashboardPageEditor`, `ChatPageEditor`, `LogsPageEditor`, +`CustomPageEditor`, `SettingsPageEditor`, and `StubPageEditor`. Each editor +SHALL expose a +`validatedConfigKeys` set marking which keys passed validation. Register/schema +backed editors SHALL fetch their option lists (`fetchRegisters`, +`fetchSchemas`, `fetchSchemaProperties`) and the detail/index editors SHALL +manage the sidebar shape (`sidebarShape`, `setSidebarShape`, `updateSidebar`, +`updateSidebarKey`, `onSidebarToggle`, `sidebarEnabled`). Form/log/chat editors +SHALL manage their transport/submit/source shape (`submitShape`, `sourceShape`, +`transportShape`, `setSubmitHandler`, `setSubmitEndpoint`, `setSourceShape`, +`setTransport`). Editors with a setup composable SHALL wire it in `setup`. + +#### Scenario: Edit a config field + +- **WHEN** the user edits a config field in a sub-editor +- **THEN** the editor emits the updated config slice and recomputes + `validatedConfigKeys` + +#### Scenario: Populate register/schema pickers + +- **WHEN** a register/schema-backed sub-editor mounts +- **THEN** it fetches the register, schema, and property option lists + +### Requirement: Reusable field builders edit list-shaped config + +Each field builder SHALL expose a local working copy of its list, support +add/remove/reorder of rows, edit per-row fields, and emit the updated list +upward. The field builders are `ColumnBuilder`, `ActionBuilder`, +`FormFieldBuilder`, `LayoutItemBuilder`, `WidgetBuilder`, `SidebarTabBuilder`, +`SidebarSectionBuilder`, and `SettingsSectionBuilder`; the menu/page list +editors are `MenuTreeEditor` and `PageListEditor`. Each builder SHALL hold a +local working copy of its list (`localColumns`, `localActions`, `localFields`, +`localItems`, `localWidgets`, `localTabs`, `localSections`), support +add/remove/reorder of rows (`addColumn`/`removeColumn`, +`addAction`/`removeAction`, `addField`/`removeField`, `addItem`/`removeItem`, +`addWidget`/`removeWidget`, `addTab`/`removeTab`, `addSection`/`removeSection`, +`addChild`/`removeChild`/`addEntry`/`removeEntry`, `moveUp`/`moveDown`, +`onReorder`/`onTopLevelReorder`/`onChildrenReorder`), edit per-row fields +(`updateField`, `updateChildField`, `updateActionField`, `updateTabField`, +`updateColumns`, `updateWidget`, `onKeyChange`, `onLabelInput`, `updateNum`), +and emit the updated list upward (`emit`). Row-render helpers (`rowKey`, +`rowLabel`, `schemaPropertyKeys`, `bodyKind`, `setBodyKind`, `stringifyProps`, +`onPropsInput`, `onWidgetPropsInput`) SHALL shape per-row display. The page-list +editor SHALL validate uniqueness and route patterns (`duplicateIds`, +`invalidRoutes`, `hasError`, `confirmAdd`, `startAdd`, `cancelAdd`). + +#### Scenario: Add and reorder a row + +- **WHEN** the user adds a row and moves it up or down +- **THEN** the builder updates its local list and emits the reordered list + +#### Scenario: Reject duplicate page ids + +- **WHEN** the user adds a page whose id duplicates an existing one +- **THEN** the page-list editor flags the error and blocks confirmation + +### Requirement: Inline validation surface and config-field registration + +`PageDesigner` SHALL provide a `pageEditorValidator` to descendant sub-editors +(`provide`), let fields register/unregister for validation +(`registerConfigField`, `unregisterConfigField`, `configPathPrefix`, +`configErrorFor`), aggregate validator errors for the right-hand panel +(`validatorErrors`), enforce menu-nesting depth limits (`onDepthViolation`), +and wire its reactive state in `setup`. The `CustomPageEditor` SHALL expose the +customComponents registry keys (`registryKeys`, `otherKeys`, +`validatedConfigKeys`) and stringify free-form props (`stringifyProps`, +`onPropsInput`, `handler`). + +#### Scenario: Inline validation mark + +- **WHEN** a registered config field fails validation +- **THEN** the field shows its error and it is aggregated into `validatorErrors` + +#### Scenario: Depth-limit guard + +- **WHEN** a menu edit would exceed the two-level nesting limit +- **THEN** the designer rejects it via `onDepthViolation` diff --git a/openspec/specs/template-catalogue-ui/spec.md b/openspec/specs/template-catalogue-ui/spec.md new file mode 100644 index 00000000..704c0916 --- /dev/null +++ b/openspec/specs/template-catalogue-ui/spec.md @@ -0,0 +1,54 @@ +--- +retrofit: true +--- + +# template-catalogue-ui Specification + +## Purpose + +The template-catalogue UI is OpenBuilt's starter-template gallery. +`TemplateGallery` fetches `ApplicationTemplate` records, filters by category, +resolves per-template screenshots, and opens the clone dialog; +`CloneTemplateDialog` validates the clone target, submits, and redirects to the +new application. + +This capability is observed behaviour of those components. It is the frontend +half of the `openbuilt-template-catalogue` backend capability. + +## Requirements + +### Requirement: Gallery fetches, filters and resolves template screenshots + +`TemplateGallery` SHALL fetch the available templates (`fetchTemplates`), +expose category filter options and the current filtered set +(`categoryOptions`, `categoryLabel`, `filteredTemplates`), resolve each +template's screenshot with a fallback (`resolveScreenshot`), open the clone +modal (`openClone`), and redirect after a successful clone +(`onCloneSubmit`, `redirectAfterClone`). + +#### Scenario: Filter by category + +- **WHEN** the user selects a category +- **THEN** the gallery narrows the visible templates to that category + +#### Scenario: Open clone + +- **WHEN** the user clicks "Use this template" +- **THEN** the gallery opens the clone dialog seeded with that template + +### Requirement: Clone dialog validates, submits and redirects + +`CloneTemplateDialog` SHALL open seeded from a template (`open`, +`resolvedTitle`), gate submission on a valid target (`canSubmit`), submit the +clone (`submit`), surface errors (`setError`), and close (`onClose`). On success +the gallery SHALL redirect to the new application. + +#### Scenario: Reject an empty target + +- **WHEN** the clone target is incomplete +- **THEN** `canSubmit` is false and submission is blocked + +#### Scenario: Redirect after clone + +- **WHEN** the clone succeeds +- **THEN** the dialog closes and the gallery redirects to the new application diff --git a/openspec/specs/version-routing-ui/spec.md b/openspec/specs/version-routing-ui/spec.md new file mode 100644 index 00000000..1e2a4d89 --- /dev/null +++ b/openspec/specs/version-routing-ui/spec.md @@ -0,0 +1,78 @@ +--- +retrofit: true +--- + +# version-routing-ui Specification + +## Purpose + +The version-routing UI exposes OpenBuilt's two-object version model to the +maintainer: `VersionHistory` lists OR object-time-travel snapshots with compare ++ rollback, `PromoteVersionDialog` moves a manifest/schema/data set to a +downstream version with a computed default strategy and a destructive-confirm +gate, `RollbackConfirmModal` gates the revert, and the version composables +(`useApplicationVersion`, `useManifestHistory`) resolve the active version and +load history. + +This capability is observed behaviour of those components. It is the frontend +half of the `version-routing`, `version-promotion`, and +`openbuilt-version-snapshots` backend capabilities. + +## Requirements + +### Requirement: Version history lists snapshots and gates compare and rollback + +`VersionHistory` SHALL load the time-travel snapshot rows (`refresh`, +`handler`), expose per-row display accessors (`rowKey`, `rowUuid`, +`rowVersion`, `rowNotes`, `rowPublishedAt`, `rowPublishedBy`, +`rowApplicationUuid`, `formatDate`), let the user compare two snapshots +(`compare`), and route rollback through a confirm flow +(`askRollback`, `onRollbackConfirmed`, `onRollbackCancelled`). + +#### Scenario: List snapshots + +- **WHEN** the version-history view loads +- **THEN** it renders one row per time-travel snapshot with author and timestamp + +#### Scenario: Rollback through confirm + +- **WHEN** the user requests a rollback +- **THEN** a confirm flow gates the revert until confirmed + +### Requirement: Promote dialog computes strategy and gates the destructive confirm + +`PromoteVersionDialog` SHALL bind the application and target version +(`application`, `targetVersion`), compute a default promotion strategy +(`computeDefaultStrategy`), render the summary and confirm helper text +(`summaryText`, `confirmHelperText`, `confirmInputLabel`), enforce a +destructive-confirm gate (`isDestructiveGateMet`), and emit confirm/cancel +(`onConfirm`, `onCancel`). + +#### Scenario: Gate a destructive promotion + +- **WHEN** the promotion would overwrite downstream data +- **THEN** the dialog requires the typed confirmation before `onConfirm` fires + +### Requirement: Rollback confirm modal gates the revert + +`RollbackConfirmModal` SHALL render the target title and formatted publish time +(`title`, `formattedPublishedAt`), track open state (`onUpdateOpen`), and emit +confirm/cancel (`confirm`, `cancel`). + +#### Scenario: Confirm a rollback + +- **WHEN** the user confirms in the rollback modal +- **THEN** the modal emits the confirm event with the target snapshot + +### Requirement: Version composables resolve active version and manifest history + +`useApplicationVersion(appSlug, versionSlug)` SHALL resolve the active version, +exposing a default editable version helper (`defaultEditableVersion`). +`useManifestHistory` SHALL load the manifest history for a version. These +composables SHALL return reactive state consumed by the builder hosts and the +detail header. + +#### Scenario: Resolve the active version + +- **WHEN** a builder host invokes `useApplicationVersion` with a slug pair +- **THEN** the composable resolves the matching version or signals not-found From 13793334c50c4a1c1faae9bc72204bf4413e711f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 26 May 2026 00:41:20 +0200 Subject: [PATCH 4/4] retrofit: annotate 402 frontend/backend methods with @spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert @spec docblock references above 402 previously-uncovered declarations across 65 files (53 Vue, 11 JS, 1 PHP), pointing at the 7 new reverse-spec UI changes. Comment insertions only — no code lines changed (0 deletions). gate-16 spec-coverage report: 0 uncovered. --- lib/Controller/PreferencesController.php | 2 + src/App.vue | 10 ++ src/components/ApplicationCard.vue | 38 +++++ src/components/ApplicationDetailActions.vue | 25 +++ src/components/ManifestDiff.vue | 55 ++++++ src/components/VirtualAppsActions.vue | 5 + .../ApplicationDetailHeader.vue | 158 ++++++++++++++++++ .../widgets/GroupsWidget.vue | 4 + .../applicationDetail/widgets/MenuWidget.vue | 1 + .../applicationDetail/widgets/PagesWidget.vue | 1 + .../widgets/RegisterWidget.vue | 2 + .../widgets/SchemasWidget.vue | 3 + src/components/page-editor/ChatPageEditor.vue | 25 +++ .../page-editor/CustomPageEditor.vue | 40 +++++ .../page-editor/DashboardPageEditor.vue | 10 ++ .../page-editor/DetailPageEditor.vue | 60 +++++++ .../page-editor/FilesPageEditor.vue | 25 +++ src/components/page-editor/FormPageEditor.vue | 30 ++++ .../page-editor/IndexPageEditor.vue | 55 ++++++ src/components/page-editor/LogsPageEditor.vue | 50 ++++++ src/components/page-editor/MenuTreeEditor.vue | 50 ++++++ src/components/page-editor/PageListEditor.vue | 45 +++++ .../page-editor/SettingsPageEditor.vue | 40 +++++ src/components/page-editor/StubPageEditor.vue | 10 ++ .../page-editor/fields/ActionBuilder.vue | 20 +++ .../page-editor/fields/ColumnBuilder.vue | 40 +++++ .../page-editor/fields/FormFieldBuilder.vue | 20 +++ .../page-editor/fields/LayoutItemBuilder.vue | 25 +++ .../fields/SettingsSectionBuilder.vue | 65 +++++++ .../fields/SidebarSectionBuilder.vue | 25 +++ .../page-editor/fields/SidebarTabBuilder.vue | 20 +++ .../page-editor/fields/WidgetBuilder.vue | 20 +++ src/components/tabs/ApplicationIconTab.vue | 5 + .../tabs/ApplicationManifestTab.vue | 15 ++ .../tabs/ApplicationVersionsTab.vue | 10 ++ src/composables/useApplicationInsights.js | 1 + src/composables/useApplicationVersion.js | 2 + src/composables/useLivePreview.js | 5 + src/composables/useManifestHistory.js | 1 + src/composables/useManifestValidator.js | 5 + src/composables/useRegisterPicker.js | 1 + src/composables/useRole.js | 3 + src/dialogs/CreateApplicationWizard.vue | 51 ++++++ .../CreateApplicationWizard/Step1Basics.vue | 30 ++++ .../CreateApplicationWizard/Step2Preset.vue | 10 ++ .../CreateApplicationWizard/Step3Custom.vue | 90 ++++++++++ .../CreateApplicationWizard/Step4Review.vue | 25 +++ src/dialogs/ExportDialog.vue | 30 ++++ src/dialogs/IconUploadSection.vue | 65 +++++++ src/dialogs/PromoteVersionDialog.vue | 17 ++ src/modals/CloneTemplateDialog.vue | 30 ++++ src/modals/PermissionsModal.vue | 25 +++ src/modals/RollbackConfirmModal.vue | 25 +++ src/store/modules/object.js | 15 ++ src/store/modules/settings.js | 10 ++ src/store/store.js | 5 + src/utils/slugPattern.js | 2 + src/views/BuilderHost.vue | 39 +++++ src/views/ExportJobsList.vue | 30 ++++ src/views/PageDesigner.vue | 130 ++++++++++++++ src/views/PageDesignerHost.vue | 24 +++ src/views/TemplateGallery.vue | 40 +++++ src/views/VersionHistory.vue | 70 ++++++++ src/views/settings/AdminRoot.vue | 5 + src/views/settings/Settings.vue | 10 ++ 65 files changed, 1805 insertions(+) diff --git a/lib/Controller/PreferencesController.php b/lib/Controller/PreferencesController.php index 8a51e2cd..4d098cb4 100644 --- a/lib/Controller/PreferencesController.php +++ b/lib/Controller/PreferencesController.php @@ -62,6 +62,7 @@ public function __construct( * * @NoAdminRequired * @NoCSRFRequired + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-5 */ public function getPreference(string $key): JSONResponse { @@ -101,6 +102,7 @@ public function getPreference(string $key): JSONResponse * * @NoAdminRequired * @NoCSRFRequired + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-5 */ public function setPreference(string $key, string $value=''): JSONResponse { diff --git a/src/App.vue b/src/App.vue index 770defcb..dbbca090 100644 --- a/src/App.vue +++ b/src/App.vue @@ -112,6 +112,7 @@ export default { * The current user's Nextcloud permission flags, passed to CnAppNav. * * @return {Array} Permission identifiers (empty when unavailable). + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 */ permissions() { return window.OC?.currentUser?.permissions ?? [] @@ -122,6 +123,7 @@ export default { * "Install OpenRegister" button in the dependency-missing slot. * * @return {boolean} True for admins. + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 */ isAdmin() { try { @@ -135,6 +137,7 @@ export default { * Path to the white-on-transparent app icon for the empty state. * * @return {string} Image path. + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 */ appIcon() { return imagePath('openbuilt', 'app-dark.svg') @@ -144,12 +147,18 @@ export default { * Deep link to OpenRegister's app-store entry. * * @return {string} Settings URL. + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 */ appStoreUrl() { return generateUrl('/settings/apps/integration/openregister') }, }, + /** + * Observed behaviour of `created` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ async created() { // Pinia stores still come up so the legacy views (settings store, // schema designer, etc.) keep working. CnAppRoot doesn't depend on @@ -169,6 +178,7 @@ export default { * * @param {string} key Translation key. * @return {string} Translated string (or the key on miss). + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 */ translateForApp(key) { return ncT('openbuilt', key) diff --git a/src/components/ApplicationCard.vue b/src/components/ApplicationCard.vue index 8701c5a3..d73e8174 100644 --- a/src/components/ApplicationCard.vue +++ b/src/components/ApplicationCard.vue @@ -57,6 +57,11 @@ export default { }, emits: ['click', 'select'], computed: { + /** + * Observed behaviour of `app` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ app() { return this.object || this.item || {} }, @@ -70,6 +75,7 @@ export default { * status badge and version chip stay accurate. * * @return {object|null} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 */ productionVersion() { const pv = this.app.productionVersion @@ -83,6 +89,7 @@ export default { * loading / when the application has no production version yet. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 */ productionSemver() { return (this.productionVersion && this.productionVersion.semver) || '—' @@ -90,6 +97,11 @@ export default { // CnDetailPage reads :objectId from $route.params, which we set here. // OR returns the canonical id under @self.id; fall back to uuid/id for // objects coming from older mock fixtures or pre-@self responses. + /** + * Observed behaviour of `appUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ appUuid() { const self = this.app['@self'] || {} return self.id || this.app.uuid || this.app.id || '' @@ -100,11 +112,17 @@ export default { * sensible default while loading or for brand-new applications. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 */ statusKey() { const status = this.productionVersion && this.productionVersion.status return ['draft', 'published', 'archived'].includes(status) ? status : 'draft' }, + /** + * Observed behaviour of `statusLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ statusLabel() { return { draft: t('openbuilt', 'Draft'), @@ -112,9 +130,19 @@ export default { archived: t('openbuilt', 'Archived'), }[this.statusKey] }, + /** + * Observed behaviour of `role` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ role() { return useRole(this.app, getCurrentUserGroups()) }, + /** + * Observed behaviour of `roleLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ roleLabel() { return { owner: t('openbuilt', 'Owner'), @@ -124,9 +152,19 @@ export default { }, }, methods: { + /** + * Observed behaviour of `onIconError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ onIconError(e) { e.target.src = '/apps/openbuilt/img/app.svg' }, + /** + * Observed behaviour of `onCardActivate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-3 + */ onCardActivate(event) { this.$emit('click', event) if (this.$router) { diff --git a/src/components/ApplicationDetailActions.vue b/src/components/ApplicationDetailActions.vue index 1364d847..b1866db1 100644 --- a/src/components/ApplicationDetailActions.vue +++ b/src/components/ApplicationDetailActions.vue @@ -63,15 +63,30 @@ export default { } }, computed: { + /** + * Observed behaviour of `canPublish` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ canPublish() { return !!this.obApp && (this.obApp.status === 'draft' || this.obApp.status === 'published') }, + /** + * Observed behaviour of `builderUrl` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ builderUrl() { if (!this.obApp || !(this.obApp.currentVersion || this.obApp.status === 'published')) { return '' } return generateUrl(`/apps/openbuilt/builder/${this.obApp.slug}`) }, + /** + * Observed behaviour of `availableGroups` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ availableGroups() { const perms = (this.obApp && this.obApp.permissions) || {} const gids = new Set(getCurrentUserGroups()) @@ -84,6 +99,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `publish` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ async publish() { if (this.obAppRole !== 'owner' || !this.obApp || this.publishing) { return @@ -107,6 +127,11 @@ export default { this.publishing = false } }, + /** + * Observed behaviour of `onPermissionsSave` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ async onPermissionsSave(permissions) { if (this.obAppRole !== 'owner' || !this.obApp) { return diff --git a/src/components/ManifestDiff.vue b/src/components/ManifestDiff.vue index 97c7e02f..1794d9a2 100644 --- a/src/components/ManifestDiff.vue +++ b/src/components/ManifestDiff.vue @@ -63,15 +63,30 @@ export default { } }, computed: { + /** + * Observed behaviour of `fromLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ fromLabel() { return this.from === 'draft' ? t('openbuilt', 'Current draft') : (this.from.slice(0, 8) + '…') }, + /** + * Observed behaviour of `toLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ toLabel() { return this.to === 'draft' ? t('openbuilt', 'Current draft') : (this.to ? this.to.slice(0, 8) + '…' : '—') }, hasAnyContent() { return this.fromBlob !== null || this.toBlob !== null }, + /** + * Observed behaviour of `diffParts` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ diffParts() { const fromText = this.prettyManifest(this.fromBlob?.manifest) const toText = this.prettyManifest(this.toBlob?.manifest) @@ -82,22 +97,47 @@ export default { }, }, watch: { + /** + * Observed behaviour of `from` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ from() { this.fetch() }, + /** + * Observed behaviour of `to` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ to() { this.fetch() }, + /** + * Observed behaviour of `slug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ slug() { this.fetch() }, }, + /** + * Observed behaviour of `mounted` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ mounted() { if (this.slug && this.to) { this.fetch() } }, methods: { + /** + * Observed behaviour of `fetch` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ async fetch() { if (!this.slug || !this.to) { return @@ -119,12 +159,22 @@ export default { this.loading = false } }, + /** + * Observed behaviour of `prettyManifest` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ prettyManifest(value) { if (value === null || value === undefined) { return '' } return JSON.stringify(value, this.sortReplacer.bind(this), 2) }, + /** + * Observed behaviour of `sortReplacer` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ sortReplacer(_key, val) { if (val && typeof val === 'object' && !Array.isArray(val)) { const sorted = {} @@ -135,6 +185,11 @@ export default { } return val }, + /** + * Observed behaviour of `partClass` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-5 + */ partClass(part) { if (part.added) { return 'manifest-diff__part manifest-diff__part--added' diff --git a/src/components/VirtualAppsActions.vue b/src/components/VirtualAppsActions.vue index 9db5568d..8ff8b530 100644 --- a/src/components/VirtualAppsActions.vue +++ b/src/components/VirtualAppsActions.vue @@ -45,6 +45,11 @@ export default { }, methods: { + /** + * Observed behaviour of `onWizardCreated` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ onWizardCreated(applicationUuid) { this.showWizard = false diff --git a/src/components/applicationDetail/ApplicationDetailHeader.vue b/src/components/applicationDetail/ApplicationDetailHeader.vue index 61c263c1..0ac2386a 100644 --- a/src/components/applicationDetail/ApplicationDetailHeader.vue +++ b/src/components/applicationDetail/ApplicationDetailHeader.vue @@ -226,54 +226,124 @@ export default { } }, computed: { + /** + * Observed behaviour of `appSlug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ appSlug() { return (this.application && this.application.slug) || '' }, + /** + * Observed behaviour of `applicationName` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ applicationName() { return (this.application && this.application.name) || this.appSlug || t('openbuilt', 'Untitled application') }, + /** + * Observed behaviour of `applicationDescription` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ applicationDescription() { return (this.application && this.application.description) || '' }, + /** + * Observed behaviour of `applicationStatus` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ applicationStatus() { return (this.application && this.application.status) || t('openbuilt', 'draft') }, + /** + * Observed behaviour of `iconUrl` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ iconUrl() { if (!this.appSlug) return '' return generateUrl(`/apps/openbuilt/icons/${encodeURIComponent(this.appSlug)}.svg`) }, + /** + * Observed behaviour of `windowOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ windowOptions() { return WINDOW_OPTIONS }, + /** + * Observed behaviour of `productionVersionUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ productionVersionUuid() { const pv = this.application && this.application.productionVersion if (!pv) return null if (typeof pv === 'string') return pv return pv.uuid || pv.id || null }, + /** + * Observed behaviour of `activeVersion` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeVersion() { if (!this.selectedVersionUuid) { return this.productionVersion || (this.orderedVersions[0] || null) } return this.orderedVersions.find((v) => v.uuid === this.selectedVersionUuid) || null }, + /** + * Observed behaviour of `activeVersionUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeVersionUuid() { return this.activeVersion ? this.activeVersion.uuid : '' }, + /** + * Observed behaviour of `activeVersionSlug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeVersionSlug() { return this.activeVersion ? this.activeVersion.slug : '' }, + /** + * Observed behaviour of `activeManifest` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeManifest() { return (this.activeVersion && this.activeVersion.manifest) || {} }, + /** + * Observed behaviour of `activePages` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activePages() { const pages = this.activeManifest.pages return Array.isArray(pages) ? pages : [] }, + /** + * Observed behaviour of `activeMenu` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeMenu() { const menu = this.activeManifest.menu return Array.isArray(menu) ? menu : [] }, + /** + * Observed behaviour of `activeSchemas` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeSchemas() { const seen = new Set() const out = [] @@ -286,16 +356,36 @@ export default { }) return out }, + /** + * Observed behaviour of `schemaCount` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ schemaCount() { return this.activeSchemas.length }, + /** + * Observed behaviour of `productionVersion` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ productionVersion() { if (!this.productionVersionUuid) return null return this.orderedVersions.find((v) => v.uuid === this.productionVersionUuid) || null }, + /** + * Observed behaviour of `productionSemver` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ productionSemver() { return (this.productionVersion && this.productionVersion.semver) || '' }, + /** + * Observed behaviour of `callerRole` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ callerRole() { const permissions = (this.application && this.application.permissions) || {} const uid = this.callerUid @@ -306,6 +396,11 @@ export default { if (inBucket(permissions.viewers)) return t('openbuilt', 'viewer') return '' }, + /** + * Observed behaviour of `orderedVersions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ orderedVersions() { // Order by promotesTo chain — start from versions with no predecessor. const all = Array.isArray(this.versions) ? this.versions.slice() : [] @@ -339,6 +434,11 @@ export default { } return ordered }, + /** + * Observed behaviour of `visibleVersions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ visibleVersions() { const uid = this.callerUid const permissions = (this.application && this.application.permissions) || {} @@ -349,12 +449,27 @@ export default { return isEditorOrOwner }) }, + /** + * Observed behaviour of `filesTooltip` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ filesTooltip() { return t('openbuilt', 'count of OR-attached files across all objects in this version\'s register; storage-bytes aggregation deferred') }, + /** + * Observed behaviour of `totalActivityEvents` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ totalActivityEvents() { return this.activity.reduce((acc, b) => acc + ((b && Number(b.eventCount)) || 0), 0) }, + /** + * Observed behaviour of `sparklinePoints` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ sparklinePoints() { if (!this.activity || this.activity.length === 0) return '0,30 100,30' const max = this.activity.reduce((m, b) => Math.max(m, Number(b.eventCount) || 0), 1) || 1 @@ -364,6 +479,11 @@ export default { return `${x.toFixed(2)},${y.toFixed(2)}` }).join(' ') }, + /** + * Observed behaviour of `banner` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ banner() { if (this.versionNoLongerAccessible) { return { @@ -376,12 +496,22 @@ export default { }, }, watch: { + /** + * Observed behaviour of `object` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ object(next) { if (next) { this.application = next this.loadVersions() } }, + /** + * Observed behaviour of `objectId` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ objectId() { this.refreshApplication() }, @@ -393,13 +523,28 @@ export default { const match = this.orderedVersions.find((v) => v.slug === newSlug) if (match) this.selectedVersionUuid = match.uuid }, + /** + * Observed behaviour of `activeVersionUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ activeVersionUuid() { this.scheduleInsightsFetch() }, + /** + * Observed behaviour of `selectedWindow` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ selectedWindow() { this.scheduleInsightsFetch() }, }, + /** + * Observed behaviour of `mounted` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ mounted() { // CnDetailPage's #header slot doesn't pass the resolved object, so we // fetch the Application by UUID from the route params on mount. @@ -409,6 +554,11 @@ export default { this.loadVersions() } }, + /** + * Observed behaviour of `beforeDestroy` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 + */ beforeDestroy() { if (this.insightsDebounce) { clearTimeout(this.insightsDebounce) @@ -454,6 +604,7 @@ export default { * * @param {object} version The version row. * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ selectVersion(version) { if (!version || !version.slug) return @@ -472,6 +623,7 @@ export default { * Switch the active version to the production version (banner action). * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ switchToProduction() { const prod = this.productionVersion @@ -487,6 +639,7 @@ export default { * * @param {object} version The version row. * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ onPromoteClick(version) { const opener = (typeof window !== 'undefined' && window.openbuilt && typeof window.openbuilt.openPromoteDialog === 'function') @@ -507,6 +660,7 @@ export default { * * @param {object} application The Application record. * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ onOpenPermissions(application) { this.$emit('open-permissions', application) @@ -516,6 +670,7 @@ export default { * Re-load the Application record by `objectId`. * * @return {Promise} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ async refreshApplication() { const uuid = this.objectId || (this.$route && this.$route.params && this.$route.params.objectId) || '' @@ -542,6 +697,7 @@ export default { * existing CRUD endpoint (`/api/applications/{slug}/versions`). * * @return {Promise} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ async loadVersions() { if (!this.appSlug) return @@ -581,6 +737,7 @@ export default { * back-to-back (version, window) changes into one HTTP call. * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ scheduleInsightsFetch() { if (this.insightsDebounce) { @@ -593,6 +750,7 @@ export default { * Fetch the insights payload for the active (app, version, window). * * @return {Promise} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-1 */ async fetchInsights() { const appUuid = (this.application && (this.application.uuid || this.application.id)) || this.objectId diff --git a/src/components/applicationDetail/widgets/GroupsWidget.vue b/src/components/applicationDetail/widgets/GroupsWidget.vue index c8cbca60..3ab4fbf4 100644 --- a/src/components/applicationDetail/widgets/GroupsWidget.vue +++ b/src/components/applicationDetail/widgets/GroupsWidget.vue @@ -57,6 +57,7 @@ export default { * Each row carries `{ role, principal, label, isGroup }`. * * @return {Array} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ rows() { const permissions = (this.application && this.application.permissions) || {} @@ -82,6 +83,7 @@ export default { * * @param {string} role Role key. * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ roleLabel(role) { if (role === 'owners') return t('openbuilt', 'Owner') @@ -94,6 +96,7 @@ export default { * * @param {object} row Row. * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ memberLabel(row) { if (!row.isGroup) { @@ -110,6 +113,7 @@ export default { * this to a route. * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ openEditor() { this.$emit('open-permissions', this.application) diff --git a/src/components/applicationDetail/widgets/MenuWidget.vue b/src/components/applicationDetail/widgets/MenuWidget.vue index d5aafa95..73ac2476 100644 --- a/src/components/applicationDetail/widgets/MenuWidget.vue +++ b/src/components/applicationDetail/widgets/MenuWidget.vue @@ -48,6 +48,7 @@ export default { * Open the page designer with the menu pane focused (REQ-OBADO-010). * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ openEntry() { const route = buildVersionedRoute( diff --git a/src/components/applicationDetail/widgets/PagesWidget.vue b/src/components/applicationDetail/widgets/PagesWidget.vue index 9aae0f27..e8fd3e03 100644 --- a/src/components/applicationDetail/widgets/PagesWidget.vue +++ b/src/components/applicationDetail/widgets/PagesWidget.vue @@ -50,6 +50,7 @@ export default { * * @param {object} page The manifest page entry. * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ openPage(page) { if (!page || !page.id) { diff --git a/src/components/applicationDetail/widgets/RegisterWidget.vue b/src/components/applicationDetail/widgets/RegisterWidget.vue index c2c98cb7..ec616f23 100644 --- a/src/components/applicationDetail/widgets/RegisterWidget.vue +++ b/src/components/applicationDetail/widgets/RegisterWidget.vue @@ -63,6 +63,7 @@ export default { * `openbuilt-{appSlug}-{versionSlug}` (ADR-002 / openbuilt-versioning-model). * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ registerSlug() { return `openbuilt-${this.appSlug}-${this.versionSlug}` @@ -74,6 +75,7 @@ export default { * Nextcloud URL, not a Vue Router internal route). * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ openInOpenRegister() { const url = generateUrl(`/apps/openregister/registers/${encodeURIComponent(this.registerSlug)}`) diff --git a/src/components/applicationDetail/widgets/SchemasWidget.vue b/src/components/applicationDetail/widgets/SchemasWidget.vue index 102e4cda..3f2db11b 100644 --- a/src/components/applicationDetail/widgets/SchemasWidget.vue +++ b/src/components/applicationDetail/widgets/SchemasWidget.vue @@ -62,6 +62,7 @@ export default { * * @param {number|undefined} count Object count value. * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ formatCount(count) { const n = Number(count || 0) @@ -74,6 +75,7 @@ export default { * * @param {object} schema The schema row. * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ openSchema(schema) { const id = schema.id || schema.uuid || schema.slug @@ -93,6 +95,7 @@ export default { * scenario). * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-2 */ addSchema() { const opener = (typeof window !== 'undefined' && window.openbuilt && typeof window.openbuilt.openAddSchemaDialog === 'function') diff --git a/src/components/page-editor/ChatPageEditor.vue b/src/components/page-editor/ChatPageEditor.vue index 8dfca971..01c7d349 100644 --- a/src/components/page-editor/ChatPageEditor.vue +++ b/src/components/page-editor/ChatPageEditor.vue @@ -106,9 +106,19 @@ export default { }, emits: ['update:config'], computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['conversationSource', 'postUrl', 'schema'] }, + /** + * Observed behaviour of `transportShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ transportShape() { if (this.config.postUrl && !this.config.conversationSource) { return 'postUrl' @@ -117,6 +127,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null) { @@ -126,6 +141,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setTransportShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setTransportShape(shape) { const next = { ...this.config } if (shape === 'postUrl') { @@ -135,6 +155,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setTransport` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setTransport(key, value) { const partner = key === 'postUrl' ? 'conversationSource' : 'postUrl' const next = { ...this.config } diff --git a/src/components/page-editor/CustomPageEditor.vue b/src/components/page-editor/CustomPageEditor.vue index ddd7a9c4..7d6ecc28 100644 --- a/src/components/page-editor/CustomPageEditor.vue +++ b/src/components/page-editor/CustomPageEditor.vue @@ -91,6 +91,11 @@ export default { }, }, emits: ['update:config'], + /** + * Observed behaviour of `setup` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ setup() { // When chain spec #2's in-memory preview is wired AND it exposes a // registry list this surfaces it as suggestions; until @@ -105,9 +110,19 @@ export default { } }, computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ validatedConfigKeys() { return ['component', 'props'] }, + /** + * Observed behaviour of `registryKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ registryKeys() { const reg = this.preview && this.preview.componentRegistry if (Array.isArray(reg)) { @@ -118,12 +133,22 @@ export default { } return [] }, + /** + * Observed behaviour of `otherKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ otherKeys() { return Object.keys(this.config || {}).filter((k) => k !== 'component' && k !== 'props') }, }, watch: { 'config.props': { + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ handler(val) { const fresh = this.stringifyProps(val) if (fresh !== this.propsDraft) { @@ -134,6 +159,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `stringifyProps` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ stringifyProps(value) { if (value === undefined || value === null) { return '' @@ -144,6 +174,11 @@ export default { return '' } }, + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null || value === undefined) { @@ -153,6 +188,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `onPropsInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-5 + */ onPropsInput(value) { this.propsDraft = value const trimmed = (value || '').trim() diff --git a/src/components/page-editor/DashboardPageEditor.vue b/src/components/page-editor/DashboardPageEditor.vue index 8a41b76b..4c268b60 100644 --- a/src/components/page-editor/DashboardPageEditor.vue +++ b/src/components/page-editor/DashboardPageEditor.vue @@ -57,11 +57,21 @@ export default { }, emits: ['update:config'], computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['widgets', 'layout'] }, }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (!value || (Array.isArray(value) && value.length === 0)) { diff --git a/src/components/page-editor/DetailPageEditor.vue b/src/components/page-editor/DetailPageEditor.vue index cae5f400..c86d6f33 100644 --- a/src/components/page-editor/DetailPageEditor.vue +++ b/src/components/page-editor/DetailPageEditor.vue @@ -145,6 +145,11 @@ export default { }, }, emits: ['update:config'], + /** + * Observed behaviour of `setup` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setup(props) { const picker = useRegisterPicker({ appSlug: props.appSlug }) return { picker } @@ -156,16 +161,36 @@ export default { } }, computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['register', 'schema', 'sidebar', 'sidebarProps'] }, + /** + * Observed behaviour of `routeParams` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ routeParams() { const matches = this.parentRoute.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || [] return matches.map((m) => m.slice(1)) }, + /** + * Observed behaviour of `routeHasParam` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ routeHasParam() { return this.routeParams.length > 0 }, + /** + * Observed behaviour of `sidebarShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ sidebarShape() { const s = this.config.sidebar if (s === undefined) { @@ -180,6 +205,11 @@ export default { watch: { 'config.register': { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ handler(val) { if (val) { this.fetchSchemas(val) @@ -193,6 +223,11 @@ export default { await this.fetchRegisters() }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null) { @@ -205,6 +240,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setSidebarShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setSidebarShape(shape) { const next = { ...this.config } if (shape === 'none') { @@ -216,12 +256,22 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `updateSidebarKey` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ updateSidebarKey(key, value) { const next = { ...this.config } const current = (typeof next.sidebar === 'object' && next.sidebar) || { enabled: true } next.sidebar = { ...current, [key]: value } this.$emit('update:config', next) }, + /** + * Observed behaviour of `updateSidebarPropsTabs` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ updateSidebarPropsTabs(tabs) { const next = { ...this.config } if (!tabs || !tabs.length) { @@ -238,9 +288,19 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `fetchRegisters` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchRegisters() { this.registers = await this.picker.fetchRegisters() }, + /** + * Observed behaviour of `fetchSchemas` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchSchemas(register) { this.schemas = await this.picker.fetchSchemas(register) }, diff --git a/src/components/page-editor/FilesPageEditor.vue b/src/components/page-editor/FilesPageEditor.vue index 2d8e0150..800dd519 100644 --- a/src/components/page-editor/FilesPageEditor.vue +++ b/src/components/page-editor/FilesPageEditor.vue @@ -120,14 +120,29 @@ export default { } }, computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['folder', 'allowedTypes'] }, + /** + * Observed behaviour of `allowedTypes` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ allowedTypes() { return Array.isArray(this.config.allowedTypes) ? this.config.allowedTypes : [] }, }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null || (Array.isArray(value) && value.length === 0)) { @@ -137,6 +152,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `commitDraft` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ commitDraft() { const value = (this.typeDraft || '').trim() this.typeDraft = '' @@ -145,6 +165,11 @@ export default { } this.update('allowedTypes', [...this.allowedTypes, value]) }, + /** + * Observed behaviour of `removeType` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ removeType(index) { const next = this.allowedTypes.slice() next.splice(index, 1) diff --git a/src/components/page-editor/FormPageEditor.vue b/src/components/page-editor/FormPageEditor.vue index ec2e943b..70478287 100644 --- a/src/components/page-editor/FormPageEditor.vue +++ b/src/components/page-editor/FormPageEditor.vue @@ -137,9 +137,19 @@ export default { }, emits: ['update:config'], computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['submitHandler', 'submitEndpoint', 'submitMethod', 'mode', 'submitLabel', 'successMessage', 'fields', 'initialValue'] }, + /** + * Observed behaviour of `submitShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ submitShape() { if (this.config.submitHandler) { return 'handler' @@ -151,6 +161,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null) { @@ -160,6 +175,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setSubmitShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setSubmitShape(shape) { const next = { ...this.config } if (shape === 'handler') { @@ -169,6 +189,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setSubmitHandler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setSubmitHandler(value) { const next = { ...this.config } // Exactly-one-of: setting submitHandler clears submitEndpoint. @@ -180,6 +205,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setSubmitEndpoint` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setSubmitEndpoint(value) { const next = { ...this.config } delete next.submitHandler diff --git a/src/components/page-editor/IndexPageEditor.vue b/src/components/page-editor/IndexPageEditor.vue index bfd5da3e..a45f17c8 100644 --- a/src/components/page-editor/IndexPageEditor.vue +++ b/src/components/page-editor/IndexPageEditor.vue @@ -123,6 +123,11 @@ export default { }, }, emits: ['update:config'], + /** + * Observed behaviour of `setup` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setup(props) { const picker = useRegisterPicker({ appSlug: props.appSlug }) return { picker } @@ -135,9 +140,19 @@ export default { } }, computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['register', 'schema', 'cardComponent', 'columns', 'actions', 'sidebar'] }, + /** + * Observed behaviour of `sidebarEnabled` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ sidebarEnabled() { const s = this.config.sidebar if (s == null) { @@ -152,6 +167,11 @@ export default { watch: { 'config.register': { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ handler(val) { if (val) { this.fetchSchemas(val) @@ -162,6 +182,11 @@ export default { }, 'config.schema': { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ handler(val) { if (val && this.config.register) { this.fetchSchemaProperties(this.config.register, val) @@ -175,6 +200,11 @@ export default { await this.fetchRegisters() }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null || (Array.isArray(value) && value.length === 0)) { @@ -188,6 +218,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `onSidebarToggle` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ onSidebarToggle(enabled) { const next = { ...this.config } if (!enabled) { @@ -198,18 +233,38 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `updateSidebar` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ updateSidebar(key, value) { const next = { ...this.config } const current = (typeof next.sidebar === 'object' && next.sidebar) || { enabled: true } next.sidebar = { ...current, [key]: value } this.$emit('update:config', next) }, + /** + * Observed behaviour of `fetchRegisters` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchRegisters() { this.registers = await this.picker.fetchRegisters() }, + /** + * Observed behaviour of `fetchSchemas` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchSchemas(register) { this.schemas = await this.picker.fetchSchemas(register) }, + /** + * Observed behaviour of `fetchSchemaProperties` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchSchemaProperties(register, schema) { this.schemaProperties = await this.picker.fetchSchemaProperties(register, schema) }, diff --git a/src/components/page-editor/LogsPageEditor.vue b/src/components/page-editor/LogsPageEditor.vue index 417a1d09..023358a7 100644 --- a/src/components/page-editor/LogsPageEditor.vue +++ b/src/components/page-editor/LogsPageEditor.vue @@ -133,6 +133,11 @@ export default { }, }, emits: ['update:config'], + /** + * Observed behaviour of `setup` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setup(props) { const picker = useRegisterPicker({ appSlug: props.appSlug }) return { picker } @@ -145,9 +150,19 @@ export default { } }, computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['register', 'schema', 'source', 'columns'] }, + /** + * Observed behaviour of `sourceShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ sourceShape() { // `source` wins only when there is no register binding so a // half-edited config never silently flips branches. @@ -160,6 +175,11 @@ export default { watch: { 'config.register': { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ handler(val) { if (val) { this.fetchSchemas(val) @@ -170,6 +190,11 @@ export default { }, 'config.schema': { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ handler(val) { if (val && this.config.register) { this.fetchSchemaProperties(this.config.register, val) @@ -183,6 +208,11 @@ export default { await this.fetchRegisters() }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null || (Array.isArray(value) && value.length === 0)) { @@ -202,6 +232,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setSourceShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setSourceShape(shape) { const next = { ...this.config } if (shape === 'source') { @@ -212,12 +247,27 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `fetchRegisters` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchRegisters() { this.registers = await this.picker.fetchRegisters() }, + /** + * Observed behaviour of `fetchSchemas` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchSchemas(register) { this.schemas = await this.picker.fetchSchemas(register) }, + /** + * Observed behaviour of `fetchSchemaProperties` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ async fetchSchemaProperties(register, schema) { this.schemaProperties = await this.picker.fetchSchemaProperties(register, schema) }, diff --git a/src/components/page-editor/MenuTreeEditor.vue b/src/components/page-editor/MenuTreeEditor.vue index 733c929a..4377de18 100644 --- a/src/components/page-editor/MenuTreeEditor.vue +++ b/src/components/page-editor/MenuTreeEditor.vue @@ -173,11 +173,21 @@ export default { } }, methods: { + /** + * Observed behaviour of `emit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ emit(menu) { // Re-assign monotonic `order` integers per top-level entry. const next = menu.map((e, i) => ({ ...e, order: i })) this.$emit('update:menu', next) }, + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.menu.slice() const current = { ...next[index] } @@ -189,6 +199,11 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `updateActionField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateActionField(index, value) { const next = this.menu.slice() const current = { ...next[index] } @@ -203,16 +218,31 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `addEntry` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addEntry() { const next = this.menu.slice() next.push({ id: '', label: '', target: 'main' }) this.emit(next) }, + /** + * Observed behaviour of `removeEntry` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeEntry(index) { const next = this.menu.slice() next.splice(index, 1) this.emit(next) }, + /** + * Observed behaviour of `addChild` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addChild(index) { const next = this.menu.slice() const current = { ...next[index] } @@ -222,6 +252,11 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `updateChildField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateChildField(index, cIndex, key, value) { const next = this.menu.slice() const parent = { ...next[index] } @@ -243,6 +278,11 @@ export default { next[index] = parent this.emit(next) }, + /** + * Observed behaviour of `removeChild` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeChild(index, cIndex) { const next = this.menu.slice() const parent = { ...next[index] } @@ -256,9 +296,19 @@ export default { next[index] = parent this.emit(next) }, + /** + * Observed behaviour of `onTopLevelReorder` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onTopLevelReorder(newOrder) { this.emit(newOrder) }, + /** + * Observed behaviour of `onChildrenReorder` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onChildrenReorder(index, newOrder) { const next = this.menu.slice() const parent = { ...next[index] } diff --git a/src/components/page-editor/PageListEditor.vue b/src/components/page-editor/PageListEditor.vue index c03fdfb6..4f412c29 100644 --- a/src/components/page-editor/PageListEditor.vue +++ b/src/components/page-editor/PageListEditor.vue @@ -131,6 +131,11 @@ export default { } }, computed: { + /** + * Observed behaviour of `duplicateIds` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ duplicateIds() { const counts = new Map() for (const p of this.pages) { @@ -140,6 +145,11 @@ export default { } return Array.from(counts.entries()).filter(([, c]) => c > 1).map(([id]) => id) }, + /** + * Observed behaviour of `invalidRoutes` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ invalidRoutes() { return this.pages .filter((p) => p && p.route && !ROUTE_PATTERN.test(p.route)) @@ -147,12 +157,27 @@ export default { }, }, methods: { + /** + * Observed behaviour of `startAdd` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ startAdd() { this.addingType = '' }, + /** + * Observed behaviour of `cancelAdd` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ cancelAdd() { this.addingType = null }, + /** + * Observed behaviour of `confirmAdd` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ confirmAdd() { if (!this.addingType) { return @@ -171,6 +196,11 @@ export default { this.$emit('select', next.length - 1) this.addingType = null }, + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.pages.slice() const current = { ...next[index] } @@ -182,6 +212,11 @@ export default { next[index] = current this.$emit('update:pages', next) }, + /** + * Observed behaviour of `removePage` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removePage(index) { const next = this.pages.slice() next.splice(index, 1) @@ -190,9 +225,19 @@ export default { this.$emit('select', -1) } }, + /** + * Observed behaviour of `onReorder` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onReorder(newOrder) { this.$emit('update:pages', newOrder) }, + /** + * Observed behaviour of `hasError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ hasError(page, index) { if (this.duplicateIds.includes(page && page.id)) { return true diff --git a/src/components/page-editor/SettingsPageEditor.vue b/src/components/page-editor/SettingsPageEditor.vue index ea7ea9ce..f172b328 100644 --- a/src/components/page-editor/SettingsPageEditor.vue +++ b/src/components/page-editor/SettingsPageEditor.vue @@ -143,9 +143,19 @@ export default { }, emits: ['update:config'], computed: { + /** + * Observed behaviour of `validatedConfigKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ validatedConfigKeys() { return ['saveEndpoint', 'sections', 'tabs'] }, + /** + * Observed behaviour of `layoutShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ layoutShape() { // `tabs` wins only when there is no `sections` array so a // half-edited config never silently flips branches. @@ -154,11 +164,21 @@ export default { } return 'sections' }, + /** + * Observed behaviour of `tabs` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ tabs() { return Array.isArray(this.config.tabs) ? this.config.tabs : [] }, }, methods: { + /** + * Observed behaviour of `update` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ update(key, value) { const next = { ...this.config } if (value === '' || value === null || value === undefined @@ -169,6 +189,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `setLayoutShape` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ setLayoutShape(shape) { const next = { ...this.config } if (shape === 'tabs') { @@ -184,6 +209,11 @@ export default { } this.$emit('update:config', next) }, + /** + * Observed behaviour of `updateTabField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ updateTabField(index, key, value) { const next = { ...this.config } const tabsArr = (next.tabs || []).slice() @@ -197,12 +227,22 @@ export default { next.tabs = tabsArr this.$emit('update:config', next) }, + /** + * Observed behaviour of `addTab` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ addTab() { const next = { ...this.config } next.tabs = [...(next.tabs || []), { id: '', label: '', sections: [] }] delete next.sections this.$emit('update:config', next) }, + /** + * Observed behaviour of `removeTab` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ removeTab(index) { const next = { ...this.config } const tabsArr = (next.tabs || []).slice() diff --git a/src/components/page-editor/StubPageEditor.vue b/src/components/page-editor/StubPageEditor.vue index d6b50b90..3f52ffa3 100644 --- a/src/components/page-editor/StubPageEditor.vue +++ b/src/components/page-editor/StubPageEditor.vue @@ -49,6 +49,11 @@ export default { watch: { config: { deep: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ handler(val) { try { const fresh = JSON.stringify(val || {}, null, 2) @@ -62,6 +67,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `onInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-3 + */ onInput(value) { this.jsonDraft = value try { diff --git a/src/components/page-editor/fields/ActionBuilder.vue b/src/components/page-editor/fields/ActionBuilder.vue index 3c1fefa1..2dcb5d6b 100644 --- a/src/components/page-editor/fields/ActionBuilder.vue +++ b/src/components/page-editor/fields/ActionBuilder.vue @@ -66,11 +66,21 @@ export default { }, emits: ['update:modelValue'], computed: { + /** + * Observed behaviour of `localActions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localActions() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localActions.slice() const current = next[index] || {} @@ -82,11 +92,21 @@ export default { } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `addAction` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addAction() { const next = this.localActions.slice() next.push({ id: '', label: '' }) this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeAction` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeAction(index) { const next = this.localActions.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/ColumnBuilder.vue b/src/components/page-editor/fields/ColumnBuilder.vue index c48ae19c..c62cf084 100644 --- a/src/components/page-editor/fields/ColumnBuilder.vue +++ b/src/components/page-editor/fields/ColumnBuilder.vue @@ -75,26 +75,51 @@ export default { } }, computed: { + /** + * Observed behaviour of `localColumns` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localColumns() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, + /** + * Observed behaviour of `schemaPropertyKeys` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ schemaPropertyKeys() { return Object.keys(this.schemaProperties || {}) }, }, methods: { + /** + * Observed behaviour of `rowKey` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ rowKey(col) { if (typeof col === 'string') { return col } return (col && (col.key || col.property)) || '' }, + /** + * Observed behaviour of `rowLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ rowLabel(col) { if (typeof col === 'string') { return '' } return (col && col.label) || '' }, + /** + * Observed behaviour of `onKeyChange` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onKeyChange(index, value) { const next = this.localColumns.slice() const existing = next[index] @@ -106,6 +131,11 @@ export default { } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `onLabelInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onLabelInput(index, value) { const next = this.localColumns.slice() const existing = next[index] @@ -119,11 +149,21 @@ export default { } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `addColumn` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addColumn() { const next = this.localColumns.slice() next.push('') this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeColumn` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeColumn(index) { const next = this.localColumns.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/FormFieldBuilder.vue b/src/components/page-editor/fields/FormFieldBuilder.vue index c461ebbb..a73b0d35 100644 --- a/src/components/page-editor/fields/FormFieldBuilder.vue +++ b/src/components/page-editor/fields/FormFieldBuilder.vue @@ -70,11 +70,21 @@ export default { return { FIELD_TYPES } }, computed: { + /** + * Observed behaviour of `localFields` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localFields() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localFields.slice() const current = next[index] || {} @@ -86,11 +96,21 @@ export default { } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `addField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addField() { const next = this.localFields.slice() next.push({ key: '', label: '', type: 'string' }) this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeField(index) { const next = this.localFields.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/LayoutItemBuilder.vue b/src/components/page-editor/fields/LayoutItemBuilder.vue index bc476cf2..d9081e09 100644 --- a/src/components/page-editor/fields/LayoutItemBuilder.vue +++ b/src/components/page-editor/fields/LayoutItemBuilder.vue @@ -72,25 +72,50 @@ export default { }, emits: ['update:modelValue'], computed: { + /** + * Observed behaviour of `localItems` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localItems() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localItems.slice() next[index] = { ...next[index], [key]: value } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `updateNum` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateNum(index, key, value) { const num = parseInt(value, 10) this.updateField(index, key, Number.isNaN(num) ? 0 : num) }, + /** + * Observed behaviour of `addItem` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addItem() { const next = this.localItems.slice() next.push({ id: next.length + 1, widgetId: '', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2 }) this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeItem` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeItem(index) { const next = this.localItems.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/SettingsSectionBuilder.vue b/src/components/page-editor/fields/SettingsSectionBuilder.vue index 931bcd5e..dd40cd53 100644 --- a/src/components/page-editor/fields/SettingsSectionBuilder.vue +++ b/src/components/page-editor/fields/SettingsSectionBuilder.vue @@ -163,11 +163,21 @@ export default { } }, computed: { + /** + * Observed behaviour of `localSections` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localSections() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `bodyKind` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ bodyKind(section) { if (section && Array.isArray(section.widgets)) { return 'widgets' @@ -177,6 +187,11 @@ export default { } return 'fields' }, + /** + * Observed behaviour of `stringifyProps` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ stringifyProps(value) { if (value === undefined || value === null) { return '' @@ -187,9 +202,19 @@ export default { return '' } }, + /** + * Observed behaviour of `emit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ emit(sections) { this.$emit('update:modelValue', sections) }, + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localSections.slice() const current = { ...(next[index] || {}) } @@ -202,6 +227,11 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `setBodyKind` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ setBodyKind(index, kind) { const next = this.localSections.slice() const current = { ...(next[index] || {}) } @@ -220,6 +250,11 @@ export default { this.$set(this.propsError, index, '') this.emit(next) }, + /** + * Observed behaviour of `onPropsInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onPropsInput(index, value) { this.$set(this.propsDraft, index, value) const trimmed = (value || '').trim() @@ -236,6 +271,11 @@ export default { this.$set(this.propsError, index, (e && e.message) || String(e)) } }, + /** + * Observed behaviour of `addWidget` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addWidget(index) { const next = this.localSections.slice() const current = { ...(next[index] || {}) } @@ -243,6 +283,11 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `updateWidget` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateWidget(index, wIndex, key, value) { const next = this.localSections.slice() const current = { ...(next[index] || {}) } @@ -258,6 +303,11 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `onWidgetPropsInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ onWidgetPropsInput(index, wIndex, value) { const trimmed = (value || '').trim() if (trimmed === '') { @@ -271,6 +321,11 @@ export default { // settings validator surfaces the malformed state. } }, + /** + * Observed behaviour of `removeWidget` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeWidget(index, wIndex) { const next = this.localSections.slice() const current = { ...(next[index] || {}) } @@ -280,11 +335,21 @@ export default { next[index] = current this.emit(next) }, + /** + * Observed behaviour of `addSection` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addSection() { const next = this.localSections.slice() next.push({ title: '', fields: [] }) this.emit(next) }, + /** + * Observed behaviour of `removeSection` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeSection(index) { const next = this.localSections.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/SidebarSectionBuilder.vue b/src/components/page-editor/fields/SidebarSectionBuilder.vue index fe5fdf27..3d67342d 100644 --- a/src/components/page-editor/fields/SidebarSectionBuilder.vue +++ b/src/components/page-editor/fields/SidebarSectionBuilder.vue @@ -49,26 +49,51 @@ export default { }, emits: ['update:modelValue'], computed: { + /** + * Observed behaviour of `localSections` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localSections() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localSections.slice() const current = next[index] || {} next[index] = { ...current, [key]: value } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `updateColumns` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateColumns(index, value) { const cols = value.split(',').map((s) => s.trim()).filter(Boolean) this.updateField(index, 'columns', cols) }, + /** + * Observed behaviour of `addSection` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addSection() { const next = this.localSections.slice() next.push({ id: '', label: '', columns: [] }) this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeSection` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeSection(index) { const next = this.localSections.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/SidebarTabBuilder.vue b/src/components/page-editor/fields/SidebarTabBuilder.vue index 2975c442..2d14004e 100644 --- a/src/components/page-editor/fields/SidebarTabBuilder.vue +++ b/src/components/page-editor/fields/SidebarTabBuilder.vue @@ -59,11 +59,21 @@ export default { }, emits: ['update:modelValue'], computed: { + /** + * Observed behaviour of `localTabs` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localTabs() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localTabs.slice() const current = next[index] || {} @@ -75,11 +85,21 @@ export default { } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `addTab` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addTab() { const next = this.localTabs.slice() next.push({ id: '', label: '' }) this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeTab` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeTab(index) { const next = this.localTabs.slice() next.splice(index, 1) diff --git a/src/components/page-editor/fields/WidgetBuilder.vue b/src/components/page-editor/fields/WidgetBuilder.vue index 723a2727..fb93a87d 100644 --- a/src/components/page-editor/fields/WidgetBuilder.vue +++ b/src/components/page-editor/fields/WidgetBuilder.vue @@ -48,22 +48,42 @@ export default { }, emits: ['update:modelValue'], computed: { + /** + * Observed behaviour of `localWidgets` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ localWidgets() { return Array.isArray(this.modelValue) ? this.modelValue : [] }, }, methods: { + /** + * Observed behaviour of `updateField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ updateField(index, key, value) { const next = this.localWidgets.slice() const current = next[index] || {} next[index] = { ...current, [key]: value } this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `addWidget` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ addWidget() { const next = this.localWidgets.slice() next.push({ id: '', title: '', type: 'custom' }) this.$emit('update:modelValue', next) }, + /** + * Observed behaviour of `removeWidget` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-4 + */ removeWidget(index) { const next = this.localWidgets.slice() next.splice(index, 1) diff --git a/src/components/tabs/ApplicationIconTab.vue b/src/components/tabs/ApplicationIconTab.vue index 91d12cab..05122534 100644 --- a/src/components/tabs/ApplicationIconTab.vue +++ b/src/components/tabs/ApplicationIconTab.vue @@ -34,6 +34,11 @@ export default { mixins: [applicationContext], methods: { + /** + * Observed behaviour of `onIconUpdated` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ onIconUpdated(payload) { // Bubble up so the detail page can refresh the Application record. this.$emit('updated', payload) diff --git a/src/components/tabs/ApplicationManifestTab.vue b/src/components/tabs/ApplicationManifestTab.vue index 63804254..aecd2ff0 100644 --- a/src/components/tabs/ApplicationManifestTab.vue +++ b/src/components/tabs/ApplicationManifestTab.vue @@ -60,6 +60,11 @@ export default { watch: { obApp: { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ handler(app) { if (app) { this.manifestText = JSON.stringify(app.manifest || {}, null, 2) @@ -68,6 +73,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `parseAndValidate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ parseAndValidate() { let parsed try { @@ -84,6 +94,11 @@ export default { this.error = '' return parsed }, + /** + * Observed behaviour of `save` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ async save() { if (this.obAppRole !== 'editor' && this.obAppRole !== 'owner') { return diff --git a/src/components/tabs/ApplicationVersionsTab.vue b/src/components/tabs/ApplicationVersionsTab.vue index d11aa540..e7f533a8 100644 --- a/src/components/tabs/ApplicationVersionsTab.vue +++ b/src/components/tabs/ApplicationVersionsTab.vue @@ -36,6 +36,11 @@ export default { return { rollbackError: '' } }, methods: { + /** + * Observed behaviour of `shortHex` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ shortHex() { const bytes = new Uint8Array(3) if (globalThis.crypto && globalThis.crypto.getRandomValues) { @@ -47,6 +52,11 @@ export default { } return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') }, + /** + * Observed behaviour of `onRollback` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-application-detail-ui/tasks.md#task-4 + */ async onRollback(version) { if (!version || !version.manifest || !this.obApp) { return diff --git a/src/composables/useApplicationInsights.js b/src/composables/useApplicationInsights.js index 57aa644a..a8ff0028 100644 --- a/src/composables/useApplicationInsights.js +++ b/src/composables/useApplicationInsights.js @@ -39,6 +39,7 @@ import { generateUrl } from '@nextcloud/router' * versionNoLongerAccessible: import('vue').Ref, * refresh: () => Promise, * }} + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-1 */ export function useApplicationInsights(appUuidRef, versionUuidRef, windowRef) { const kpis = ref({ activeUsers: 0, objectCount: 0, filesCount: 0, auditEventCount: 0 }) diff --git a/src/composables/useApplicationVersion.js b/src/composables/useApplicationVersion.js index e4df716c..eae0c664 100644 --- a/src/composables/useApplicationVersion.js +++ b/src/composables/useApplicationVersion.js @@ -46,6 +46,7 @@ import { generateUrl } from '@nextcloud/router' * @param {Array} versions All ApplicationVersion records for the app. * @param {string|null} productionUuid UUID of the production ApplicationVersion. * @return {object|null} The selected ApplicationVersion or null when the list is empty. + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-4 */ export function defaultEditableVersion(versions, productionUuid) { if (!Array.isArray(versions) || versions.length === 0) { @@ -82,6 +83,7 @@ export function defaultEditableVersion(versions, productionUuid) { * @param {string|undefined} versionSlug The version slug (e.g. `staging`), or undefined * to trigger the most-upstream-non-production fallback. * @return {{ applicationVersion: import('vue').Ref, loading: import('vue').Ref, error: import('vue').Ref }} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-4 */ export function useApplicationVersion(appSlug, versionSlug) { /** @type {import('vue').Ref} */ diff --git a/src/composables/useLivePreview.js b/src/composables/useLivePreview.js index 47c32981..0703da39 100644 --- a/src/composables/useLivePreview.js +++ b/src/composables/useLivePreview.js @@ -17,6 +17,11 @@ import { computed } from 'vue' import { useAppManifest } from '@conduction/nextcloud-vue' +/** + * Observed behaviour of `useLivePreview` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-1 + */ export function useLivePreview() { // Spec #2 adds a second positional `manifestObject` parameter; arity // is the discriminator. Older versions ship as a 1-arg function. diff --git a/src/composables/useManifestHistory.js b/src/composables/useManifestHistory.js index 1d40afe8..1cfefac4 100644 --- a/src/composables/useManifestHistory.js +++ b/src/composables/useManifestHistory.js @@ -27,6 +27,7 @@ const DEFAULT_LIMIT = 50 * @param {object} [opts] - options. * @param {number} [opts.limit] - max stack depth (default 50). * @return {object} { push, undo, redo, reset, canUndo, canRedo, size } + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-4 */ export function useManifestHistory(initial = null, opts = {}) { const limit = Math.max(1, opts.limit || DEFAULT_LIMIT) diff --git a/src/composables/useManifestValidator.js b/src/composables/useManifestValidator.js index faad5825..cb8cee22 100644 --- a/src/composables/useManifestValidator.js +++ b/src/composables/useManifestValidator.js @@ -26,6 +26,11 @@ import { validateManifest } from '@conduction/nextcloud-vue' const DEBOUNCE_MS = 300 +/** + * Observed behaviour of `useManifestValidator` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-1 + */ export function useManifestValidator() { const errors = ref([]) const isValidating = ref(false) diff --git a/src/composables/useRegisterPicker.js b/src/composables/useRegisterPicker.js index 6f7824bc..99752461 100644 --- a/src/composables/useRegisterPicker.js +++ b/src/composables/useRegisterPicker.js @@ -39,6 +39,7 @@ const PICKER_HEADERS = () => ({ * picker filters to the per-app register `openbuilt-{slug}` first. * @return {object} - { fetchRegisters, fetchSchemas, fetchSchemaProperties, * resolveAppRegister }. + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-1 */ export function useRegisterPicker(opts = {}) { const appSlug = opts.appSlug || '' diff --git a/src/composables/useRole.js b/src/composables/useRole.js index f6481a92..cd771062 100644 --- a/src/composables/useRole.js +++ b/src/composables/useRole.js @@ -36,6 +36,7 @@ import { loadState } from '@nextcloud/initial-state' * the controller's 403 enforces server-side. * * @return {string[]} The caller's group IDs + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-2 */ export function getCurrentUserGroups() { try { @@ -54,6 +55,7 @@ export function getCurrentUserGroups() { * @param {Application | null | undefined} application The Application object * @param {string[]} [userGroups] Optional explicit group list (defaults to loadState) * @return {'owner'|'editor'|'viewer'|'none'} The caller's effective role + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-2 */ export function useRole(application, userGroups) { if (!application || typeof application !== 'object') { @@ -85,6 +87,7 @@ export function useRole(application, userGroups) { * @param {Application | null | undefined} application The Application object * @param {string[]} [userGroups] Optional explicit group list * @return {boolean} True when the caller has owner/editor/viewer + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-2 */ export function hasAnyRole(application, userGroups) { return useRole(application, userGroups) !== 'none' diff --git a/src/dialogs/CreateApplicationWizard.vue b/src/dialogs/CreateApplicationWizard.vue index 07e0fa08..33798ae4 100644 --- a/src/dialogs/CreateApplicationWizard.vue +++ b/src/dialogs/CreateApplicationWizard.vue @@ -171,16 +171,27 @@ export default { /** * The visual step number (1–4). When not on custom, step 3 is skipped * so the display stays sequential: 1, 2, [skip 3], 4 → shows as 1/3, 2/3, 3/3. + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 */ displayStep() { if (!this.isCustomPreset && this.step === 4) return 3 return this.step }, + /** + * Observed behaviour of `visibleStepCount` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ visibleStepCount() { return this.isCustomPreset ? 4 : 3 }, + /** + * Observed behaviour of `currentStepValid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ currentStepValid() { if (this.step === 1) return Boolean(this.payload._step1Valid) if (this.step === 2) return Boolean(this.payload._step2Valid) @@ -188,6 +199,11 @@ export default { return true }, + /** + * Observed behaviour of `allStepsValid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ allStepsValid() { const step3ok = !this.isCustomPreset || Boolean(this.payload._step3Valid) return ( @@ -199,6 +215,11 @@ export default { }, methods: { + /** + * Observed behaviour of `onModalShowUpdate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ onModalShowUpdate(value) { // Proxy NcModal's update:show event to the parent without mutating the prop. if (!value && !this.submitting) { @@ -207,10 +228,20 @@ export default { } }, + /** + * Observed behaviour of `mergePayload` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ mergePayload(partial) { this.payload = { ...this.payload, ...partial } }, + /** + * Observed behaviour of `goNext` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ goNext() { if (this.step === 2 && !this.isCustomPreset) { // Skip step 3 for canned presets. @@ -220,6 +251,11 @@ export default { } }, + /** + * Observed behaviour of `goBack` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ goBack() { if (this.step === 4 && !this.isCustomPreset) { // Jump back to step 2 (step 3 was skipped). @@ -229,6 +265,11 @@ export default { } }, + /** + * Observed behaviour of `onSubmit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ async onSubmit() { this.submitting = true this.errorMessage = null @@ -267,6 +308,11 @@ export default { } }, + /** + * Observed behaviour of `onClose` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ onClose() { if (!this.submitting) { this.$emit('update:show', false) @@ -274,6 +320,11 @@ export default { } }, + /** + * Observed behaviour of `resetState` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-1 + */ resetState() { this.step = 1 this.payload = { diff --git a/src/dialogs/CreateApplicationWizard/Step1Basics.vue b/src/dialogs/CreateApplicationWizard/Step1Basics.vue index 417eb800..c5721724 100644 --- a/src/dialogs/CreateApplicationWizard/Step1Basics.vue +++ b/src/dialogs/CreateApplicationWizard/Step1Basics.vue @@ -134,12 +134,22 @@ export default { }, computed: { + /** + * Observed behaviour of `slugError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ slugError() { if (!this.payload.slug) return null const result = validateSlug(this.payload.slug) return result.valid ? null : result.message }, + /** + * Observed behaviour of `isValid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ isValid() { return ( (this.payload.name || '').trim() !== '' @@ -156,6 +166,11 @@ export default { }, methods: { + /** + * Observed behaviour of `onNameInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ onNameInput(event) { const name = event.target.value const update = { name } @@ -168,15 +183,30 @@ export default { this.$emit('update:payload', update) }, + /** + * Observed behaviour of `onSlugInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ onSlugInput(event) { this.slugManuallyEdited = true this.$emit('update:payload', { slug: event.target.value }) }, + /** + * Observed behaviour of `onDescriptionInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ onDescriptionInput(event) { this.$emit('update:payload', { description: event.target.value }) }, + /** + * Observed behaviour of `onIconChange` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ onIconChange(field, event) { const file = event.target.files?.[0] || null this.$emit('update:payload', { [field]: file }) diff --git a/src/dialogs/CreateApplicationWizard/Step2Preset.vue b/src/dialogs/CreateApplicationWizard/Step2Preset.vue index 16ed9e5a..890a8af9 100644 --- a/src/dialogs/CreateApplicationWizard/Step2Preset.vue +++ b/src/dialogs/CreateApplicationWizard/Step2Preset.vue @@ -67,6 +67,11 @@ export default { emits: ['update:payload'], computed: { + /** + * Observed behaviour of `presetOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ presetOptions() { return [ { @@ -108,6 +113,11 @@ export default { }, methods: { + /** + * Observed behaviour of `selectPreset` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-2 + */ selectPreset(presetId) { const update = { preset: presetId } diff --git a/src/dialogs/CreateApplicationWizard/Step3Custom.vue b/src/dialogs/CreateApplicationWizard/Step3Custom.vue index f5b85142..369f5f03 100644 --- a/src/dialogs/CreateApplicationWizard/Step3Custom.vue +++ b/src/dialogs/CreateApplicationWizard/Step3Custom.vue @@ -153,6 +153,11 @@ export default { }, computed: { + /** + * Observed behaviour of `slugErrors` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ slugErrors() { return this.localVersions.map((row) => { if (!row.name) return t('openbuilt', 'Version name must not be empty.') @@ -161,6 +166,11 @@ export default { }) }, + /** + * Observed behaviour of `duplicateSlugs` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ duplicateSlugs() { const seen = {} const dupes = new Set() @@ -176,6 +186,11 @@ export default { return dupes }, + /** + * Observed behaviour of `isValid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ isValid() { if (this.localVersions.length === 0) return false if (this.duplicateSlugs.size > 0) return false @@ -190,12 +205,22 @@ export default { localVersions: { deep: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ handler() { this.emit() }, }, }, + /** + * Observed behaviour of `mounted` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ mounted() { // Emit initial validity + versions so the parent wizard shell can // enable/disable the Next button before the user makes any change. @@ -203,6 +228,11 @@ export default { }, methods: { + /** + * Observed behaviour of `emit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ emit() { const clean = this.localVersions.map(({ name, slug }) => ({ name, slug })) this.$emit('update:payload', { @@ -211,6 +241,11 @@ export default { }) }, + /** + * Observed behaviour of `getSlugError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ getSlugError(index) { if (this.isDuplicate(index)) { return t('openbuilt', `Slug \`${this.localVersions[index].slug}\` is already used in this chain`) @@ -223,6 +258,11 @@ export default { return this.duplicateSlugs.has(index) }, + /** + * Observed behaviour of `onNameInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ onNameInput(index, event) { const name = event.target.value this.localVersions[index].name = name @@ -233,15 +273,30 @@ export default { this.minRowError = null }, + /** + * Observed behaviour of `onSlugInput` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ onSlugInput(index, event) { this.localVersions[index]._slugManual = true this.localVersions[index].slug = event.target.value }, + /** + * Observed behaviour of `toggleAdvanced` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ toggleAdvanced(index) { this.$set(this.advancedOpen, index, !this.advancedOpen[index]) }, + /** + * Observed behaviour of `addRow` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ addRow() { this.localVersions.push({ name: '', @@ -252,6 +307,11 @@ export default { this.minRowError = null }, + /** + * Observed behaviour of `removeRow` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ removeRow(index) { if (this.localVersions.length <= 1) { this.minRowError = t('openbuilt', 'At least one version is required') @@ -262,26 +322,51 @@ export default { this.minRowError = null }, + /** + * Observed behaviour of `moveUp` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ moveUp(index) { if (index === 0) return const item = this.localVersions.splice(index, 1)[0] this.localVersions.splice(index - 1, 0, item) }, + /** + * Observed behaviour of `moveDown` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ moveDown(index) { if (index >= this.localVersions.length - 1) return const item = this.localVersions.splice(index, 1)[0] this.localVersions.splice(index + 1, 0, item) }, + /** + * Observed behaviour of `onDragStart` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ onDragStart(index) { this.dragFromIndex = index }, + /** + * Observed behaviour of `onDragOver` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ onDragOver(_index) { // Allow drop by calling .prevent in the template }, + /** + * Observed behaviour of `onDrop` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ onDrop(toIndex) { if (this.dragFromIndex === null || this.dragFromIndex === toIndex) return const item = this.localVersions.splice(this.dragFromIndex, 1)[0] @@ -289,6 +374,11 @@ export default { this.dragFromIndex = null }, + /** + * Observed behaviour of `onDragEnd` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-3 + */ onDragEnd() { this.dragFromIndex = null }, diff --git a/src/dialogs/CreateApplicationWizard/Step4Review.vue b/src/dialogs/CreateApplicationWizard/Step4Review.vue index e9bc948d..7ae76183 100644 --- a/src/dialogs/CreateApplicationWizard/Step4Review.vue +++ b/src/dialogs/CreateApplicationWizard/Step4Review.vue @@ -79,25 +79,50 @@ export default { }, computed: { + /** + * Observed behaviour of `versions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ versions() { return Array.isArray(this.payload.versions) ? this.payload.versions : [] }, + /** + * Observed behaviour of `chainDisplay` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ chainDisplay() { if (this.versions.length === 0) return '—' return this.versions.map(v => v.slug || v.name || '?').join(' → ') }, + /** + * Observed behaviour of `productionSlug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ productionSlug() { if (this.versions.length === 0) return '—' const last = this.versions[this.versions.length - 1] return last.slug || last.name || '—' }, + /** + * Observed behaviour of `iconLightUrl` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ iconLightUrl() { return this.payload.icon ? URL.createObjectURL(this.payload.icon) : null }, + /** + * Observed behaviour of `iconDarkUrl` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ iconDarkUrl() { return this.payload.iconDark ? URL.createObjectURL(this.payload.iconDark) : null }, diff --git a/src/dialogs/ExportDialog.vue b/src/dialogs/ExportDialog.vue index 9e622c42..6c68faff 100644 --- a/src/dialogs/ExportDialog.vue +++ b/src/dialogs/ExportDialog.vue @@ -116,15 +116,30 @@ export default { } }, computed: { + /** + * Observed behaviour of `versionOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-1 + */ versionOptions() { return this.availableVersions }, + /** + * Observed behaviour of `targetOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-1 + */ targetOptions() { return [ { label: this.t('openbuilt', 'ZIP download'), value: 'zip' }, { label: this.t('openbuilt', 'Push to GitHub'), value: 'github' }, ] }, + /** + * Observed behaviour of `licenseOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-1 + */ licenseOptions() { return [ { label: 'EUPL-1.2', value: 'EUPL-1.2' }, @@ -132,6 +147,11 @@ export default { { label: 'MIT', value: 'MIT' }, ] }, + /** + * Observed behaviour of `visibilityOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-1 + */ visibilityOptions() { return [ { label: this.t('openbuilt', 'Private'), value: 'private' }, @@ -140,12 +160,22 @@ export default { }, }, methods: { + /** + * Observed behaviour of `onClose` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-1 + */ onClose() { if (this.submitting) { return } this.$emit('close') }, + /** + * Observed behaviour of `submit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-1 + */ async submit() { this.submitting = true this.errorMessage = '' diff --git a/src/dialogs/IconUploadSection.vue b/src/dialogs/IconUploadSection.vue index 62278b14..9d71163d 100644 --- a/src/dialogs/IconUploadSection.vue +++ b/src/dialogs/IconUploadSection.vue @@ -135,14 +135,29 @@ export default { }, computed: { + /** + * Observed behaviour of `objectUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ objectUuid() { const self = this.application['@self'] || {} return self.id || this.application.uuid || this.application.id || '' }, + /** + * Observed behaviour of `iconLightUrl` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ iconLightUrl() { if (!this.objectUuid) return null return `/index.php/apps/openbuilt/icons/${this.application.slug}.svg?v=${this.lightNonce}` }, + /** + * Observed behaviour of `iconDarkUrl` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ iconDarkUrl() { if (!this.objectUuid) return null return `/index.php/apps/openbuilt/icons/${this.application.slug}-dark.svg?v=${this.darkNonce}` @@ -152,6 +167,11 @@ export default { watch: { application: { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ handler(app) { this.lightRef = app?.icon?.ref || null this.darkRef = app?.iconDark?.ref || null @@ -160,13 +180,28 @@ export default { }, methods: { + /** + * Observed behaviour of `onLightPreviewError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ onLightPreviewError(e) { e.target.style.display = 'none' }, + /** + * Observed behaviour of `onDarkPreviewError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ onDarkPreviewError(e) { e.target.style.display = 'none' }, + /** + * Observed behaviour of `validateSvgFile` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ validateSvgFile(file) { if (!file) return false if (!file.name.toLowerCase().endsWith('.svg')) { @@ -175,6 +210,11 @@ export default { return true }, + /** + * Observed behaviour of `onLightFileChange` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ async onLightFileChange(event) { this.lightError = '' const file = event.target.files?.[0] @@ -186,6 +226,11 @@ export default { await this.uploadIcon(file, 'light') }, + /** + * Observed behaviour of `onDarkFileChange` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ async onDarkFileChange(event) { this.darkError = '' const file = event.target.files?.[0] @@ -197,6 +242,11 @@ export default { await this.uploadIcon(file, 'dark') }, + /** + * Observed behaviour of `uploadIcon` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ async uploadIcon(file, variant) { if (!this.objectUuid) return this.uploading = true @@ -246,14 +296,29 @@ export default { } }, + /** + * Observed behaviour of `removeLightIcon` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ async removeLightIcon() { await this.removeIcon('light') }, + /** + * Observed behaviour of `removeDarkIcon` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ async removeDarkIcon() { await this.removeIcon('dark') }, + /** + * Observed behaviour of `removeIcon` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-creation-wizard-ui/tasks.md#task-4 + */ async removeIcon(variant) { if (!this.objectUuid) return this.uploading = true diff --git a/src/dialogs/PromoteVersionDialog.vue b/src/dialogs/PromoteVersionDialog.vue index d3eaa75a..2a01b110 100644 --- a/src/dialogs/PromoteVersionDialog.vue +++ b/src/dialogs/PromoteVersionDialog.vue @@ -151,6 +151,7 @@ export default { * Summary heading rendered above the strategy radio group. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ summaryText() { const sourceName = this.sourceVersion?.name || this.sourceVersion?.slug || '?' @@ -167,6 +168,7 @@ export default { * (case-sensitive byte-equal). * * @return {boolean} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ isDestructiveGateMet() { if (this.selectedStrategy !== 'empty-start') { @@ -181,6 +183,7 @@ export default { * i18n'd label for the destructive-confirmation input. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ confirmInputLabel() { return t('openbuilt', 'Type the application slug to confirm') @@ -190,16 +193,27 @@ export default { * Hint string under the destructive-confirmation input. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ confirmHelperText() { return t('openbuilt', "Empty start will permanently delete every row in the target's register. Type \"{slug}\" to confirm.", { slug: this.application?.slug || '' }) }, }, watch: { + /** + * Observed behaviour of `targetVersion` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 + */ targetVersion() { this.selectedStrategy = this.computeDefaultStrategy() this.typedSlug = '' }, + /** + * Observed behaviour of `application` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 + */ application() { this.selectedStrategy = this.computeDefaultStrategy() }, @@ -211,6 +225,7 @@ export default { * Mirrors `VersionPromotionService::defaultStrategyFor()` (PHP). * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ computeDefaultStrategy() { if (!this.targetVersion || !this.application) { @@ -224,6 +239,7 @@ export default { * Emit `confirm` with the chosen strategy when the gate is met. * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ onConfirm() { if (!this.isDestructiveGateMet) { @@ -237,6 +253,7 @@ export default { * Emit `cancel` and let the parent close the dialog. * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-2 */ onCancel() { this.$emit('cancel') diff --git a/src/modals/CloneTemplateDialog.vue b/src/modals/CloneTemplateDialog.vue index e464394d..8884b771 100644 --- a/src/modals/CloneTemplateDialog.vue +++ b/src/modals/CloneTemplateDialog.vue @@ -53,10 +53,20 @@ export default { } }, computed: { + /** + * Observed behaviour of `resolvedTitle` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2 + */ resolvedTitle() { if (!this.template) return '' return t('openbuilt', this.template.title || this.template.slug) }, + /** + * Observed behaviour of `canSubmit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2 + */ canSubmit() { return this.localName.trim().length > 0 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(this.localSlug) @@ -64,6 +74,11 @@ export default { }, }, watch: { + /** + * Observed behaviour of `open` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2 + */ open(value) { if (value) { this.localName = '' @@ -74,10 +89,20 @@ export default { }, }, methods: { + /** + * Observed behaviour of `onClose` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2 + */ onClose() { if (this.submitting) return this.$emit('close') }, + /** + * Observed behaviour of `submit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2 + */ async submit() { if (!this.canSubmit) { this.error = t('openbuilt', 'Provide a name and a kebab-case slug (max 32 chars).') @@ -92,6 +117,11 @@ export default { this.submitting = false } }, + /** + * Observed behaviour of `setError` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-2 + */ setError(message) { this.error = message this.submitting = false diff --git a/src/modals/PermissionsModal.vue b/src/modals/PermissionsModal.vue index a3ce1d9d..bbc916fe 100644 --- a/src/modals/PermissionsModal.vue +++ b/src/modals/PermissionsModal.vue @@ -96,6 +96,11 @@ export default { } }, computed: { + /** + * Observed behaviour of `groupOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ groupOptions() { return this.availableGroups.map(gid => ({ label: gid, value: gid })) }, @@ -103,12 +108,22 @@ export default { watch: { application: { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ handler(app) { this.syncFromApplication(app) }, }, }, methods: { + /** + * Observed behaviour of `syncFromApplication` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ syncFromApplication(app) { const perms = (app && app.permissions) || {} this.ownersModel = (perms.owners || []).map(g => ({ label: g, value: g })) @@ -117,9 +132,19 @@ export default { this.orphanError = false this.saving = false }, + /** + * Observed behaviour of `onClose` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ onClose() { this.$emit('update:open', false) }, + /** + * Observed behaviour of `save` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ async save() { const owners = this.ownersModel.map(o => o.value) const editors = this.editorsModel.map(o => o.value) diff --git a/src/modals/RollbackConfirmModal.vue b/src/modals/RollbackConfirmModal.vue index a1d515e2..43df7888 100644 --- a/src/modals/RollbackConfirmModal.vue +++ b/src/modals/RollbackConfirmModal.vue @@ -55,10 +55,20 @@ export default { }, emits: ['confirm', 'cancel', 'update:open'], computed: { + /** + * Observed behaviour of `title` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-3 + */ title() { const v = this.version?.version || '' return t('openbuilt', 'Roll back to version {version}?', { version: v }) }, + /** + * Observed behaviour of `formattedPublishedAt` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-3 + */ formattedPublishedAt() { if (!this.version?.publishedAt) { return '' @@ -71,14 +81,29 @@ export default { }, }, methods: { + /** + * Observed behaviour of `confirm` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-3 + */ confirm() { this.$emit('confirm', this.version) this.$emit('update:open', false) }, + /** + * Observed behaviour of `cancel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-3 + */ cancel() { this.$emit('cancel') this.$emit('update:open', false) }, + /** + * Observed behaviour of `onUpdateOpen` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-3 + */ onUpdateOpen(value) { if (!value) { this.$emit('cancel') diff --git a/src/store/modules/object.js b/src/store/modules/object.js index 79b064e3..8b12dd22 100644 --- a/src/store/modules/object.js +++ b/src/store/modules/object.js @@ -16,11 +16,21 @@ export const useObjectStore = defineStore('object', { }), actions: { + /** + * Observed behaviour of `configure` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-3 + */ configure({ baseUrl, schemaBaseUrl }) { this.baseUrl = baseUrl this.schemaBaseUrl = schemaBaseUrl }, + /** + * Observed behaviour of `registerObjectType` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-3 + */ registerObjectType(type, schema, register) { this.objectTypes[type] = { schema, register } if (!this.objects[type]) { @@ -28,6 +38,11 @@ export const useObjectStore = defineStore('object', { } }, + /** + * Observed behaviour of `fetchObjects` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-3 + */ async fetchObjects(type, params = {}) { if (!this.objectTypes[type]) { console.warn(`Object type "${type}" is not registered`) diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index e925cd9e..d6fae4be 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -17,6 +17,11 @@ export const useSettingsStore = defineStore('settings', { }, actions: { + /** + * Observed behaviour of `fetchSettings` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-3 + */ async fetchSettings() { this.loading = true try { @@ -38,6 +43,11 @@ export const useSettingsStore = defineStore('settings', { return null }, + /** + * Observed behaviour of `saveSettings` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-3 + */ async saveSettings(settings) { this.loading = true try { diff --git a/src/store/store.js b/src/store/store.js index 39d59ba8..60c6812c 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -3,6 +3,11 @@ import { generateUrl } from '@nextcloud/router' import { useObjectStore } from './modules/object.js' import { useSettingsStore } from './modules/settings.js' +/** + * Observed behaviour of `initializeStores` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-3 + */ export async function initializeStores() { const settingsStore = useSettingsStore() const objectStore = useObjectStore() diff --git a/src/utils/slugPattern.js b/src/utils/slugPattern.js index c5dffff3..3e2f42e6 100644 --- a/src/utils/slugPattern.js +++ b/src/utils/slugPattern.js @@ -41,6 +41,7 @@ const _slugRegex = new RegExp(SLUG_PATTERN) * @param {string} input - The raw name string * @return {string} Derived slug (may be empty when input contains only * characters that are stripped by step 4) + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 */ export function toKebabCase(input) { if (typeof input !== 'string') return '' @@ -61,6 +62,7 @@ export function toKebabCase(input) { * @param {string} slug - The slug to validate (may be derived or user-typed) * @return {{ valid: boolean, message?: string }} Validation result. * When valid is false, message contains a user-facing error string. + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 */ export function validateSlug(slug) { if (typeof slug !== 'string' || slug === '') { diff --git a/src/views/BuilderHost.vue b/src/views/BuilderHost.vue index 5cd6b02d..347baf3e 100644 --- a/src/views/BuilderHost.vue +++ b/src/views/BuilderHost.vue @@ -61,6 +61,11 @@ export default { } }, computed: { + /** + * Observed behaviour of `slug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ slug() { return this.$route.params.slug }, @@ -69,10 +74,16 @@ export default { * Underscore-prefix to avoid colliding with user-defined `?version=` params. * * @return {string|undefined} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ versionSlug() { return this.$route.query._version || undefined }, + /** + * Observed behaviour of `appId` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ appId() { return `openbuilt-${this.slug}` }, @@ -80,6 +91,7 @@ export default { * Cache key forces CnAppRoot remount when slug OR version changes. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ cacheKey() { return `${this.slug}:${this.versionSlug || 'default'}` @@ -91,13 +103,24 @@ export default { * "you can't see it" cases — no auth cue exposed to the caller. * * @return {boolean} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ versionNotFound() { return !this.versionLoading && this.versionError !== null && this.applicationVersion === null }, + /** + * Observed behaviour of `placeholderManifest` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ placeholderManifest() { return placeholderManifest }, + /** + * Observed behaviour of `manifestOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ manifestOptions() { // Forward `?_version=` to the manifest endpoint so the server resolves // the correct ApplicationVersion manifest (REQ-OBVR-001). @@ -110,13 +133,28 @@ export default { }, }, watch: { + /** + * Observed behaviour of `slug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ slug() { this.resolveVersion() }, + /** + * Observed behaviour of `versionSlug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ versionSlug() { this.resolveVersion() }, }, + /** + * Observed behaviour of `created` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ created() { // REQ-OBVR-004: resolve the active ApplicationVersion on mount. // NOTE: we do NOT call $router.replace() — that would strip ?_version= @@ -128,6 +166,7 @@ export default { * Kick off useApplicationVersion and mirror reactive state into component data. * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ resolveVersion() { this.versionError = null diff --git a/src/views/ExportJobsList.vue b/src/views/ExportJobsList.vue index d4c8dcb9..e4d881d6 100644 --- a/src/views/ExportJobsList.vue +++ b/src/views/ExportJobsList.vue @@ -75,22 +75,47 @@ export default { poller: null, } }, + /** + * Observed behaviour of `mounted` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-2 + */ mounted() { this.fetchJobs() this.poller = setInterval(this.fetchJobs, 2000) }, + /** + * Observed behaviour of `beforeDestroy` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-2 + */ beforeDestroy() { if (this.poller) { clearInterval(this.poller) } }, methods: { + /** + * Observed behaviour of `openDialog` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-2 + */ openDialog() { this.showDialog = true }, + /** + * Observed behaviour of `onQueued` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-2 + */ onQueued() { this.fetchJobs() }, + /** + * Observed behaviour of `fetchJobs` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-2 + */ async fetchJobs() { // Placeholder: real impl polls OR REST per ADR-022; the controller // deliberately does not expose CRUD on ExportJob. @@ -105,6 +130,11 @@ export default { // Silent fail; polling will retry. } }, + /** + * Observed behaviour of `statusLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-exporter-ui/tasks.md#task-2 + */ statusLabel(status) { const map = { queued: this.t('openbuilt', 'Queued'), diff --git a/src/views/PageDesigner.vue b/src/views/PageDesigner.vue index 6d893b12..b25e4c4d 100644 --- a/src/views/PageDesigner.vue +++ b/src/views/PageDesigner.vue @@ -152,6 +152,11 @@ export default { CustomPageEditor, StubPageEditor, }, + /** + * Observed behaviour of `provide` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ provide() { // Sub-editors `inject` this to (a) register their config keys with // the validator's prefix→error map and (b) read back the @@ -178,6 +183,11 @@ export default { }, }, emits: ['update:manifest', 'save-and-preview'], + /** + * Observed behaviour of `setup` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ setup(props) { const { available: previewAvailable, previewProps } = useLivePreview() const validator = useManifestValidator() @@ -195,27 +205,62 @@ export default { } }, computed: { + /** + * Observed behaviour of `pages` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ pages() { return Array.isArray(this.manifest && this.manifest.pages) ? this.manifest.pages : [] }, + /** + * Observed behaviour of `menu` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ menu() { return Array.isArray(this.manifest && this.manifest.menu) ? this.manifest.menu : [] }, + /** + * Observed behaviour of `selectedPage` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ selectedPage() { if (this.selectedIndex < 0 || this.selectedIndex >= this.pages.length) { return null } return this.pages[this.selectedIndex] }, + /** + * Observed behaviour of `validatorErrors` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ validatorErrors() { return this.validator.errors.value || [] }, + /** + * Observed behaviour of `canSaveAndPreview` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ canSaveAndPreview() { return !!this.slug && this.validatorErrors.length === 0 }, + /** + * Observed behaviour of `canUndo` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ canUndo() { return !!(this.history && this.history.canUndo.value) }, + /** + * Observed behaviour of `canRedo` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ canRedo() { return !!(this.history && this.history.canRedo.value) }, @@ -224,6 +269,11 @@ export default { manifest: { deep: true, immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ handler(m) { this.validator.validate(m) // Record every accepted manifest state. `push` no-ops on @@ -235,6 +285,11 @@ export default { }, }, }, + /** + * Observed behaviour of `mounted` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ mounted() { document.addEventListener('keydown', this.onKeydown) // REQ-OBVR-004: resolve the active ApplicationVersion on mount. @@ -264,27 +319,62 @@ export default { document.removeEventListener('keydown', this.onKeydown) }, methods: { + /** + * Observed behaviour of `subEditorFor` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ subEditorFor(type) { return SUB_EDITOR_MAP[type] || 'StubPageEditor' }, + /** + * Observed behaviour of `selectPage` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ selectPage(index) { this.selectedIndex = index }, + /** + * Observed behaviour of `emitManifest` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ emitManifest(next) { this.$emit('update:manifest', next) }, + /** + * Observed behaviour of `onPagesUpdate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ onPagesUpdate(pages) { const next = { ...(this.manifest || {}), pages } this.emitManifest(next) }, + /** + * Observed behaviour of `onMenuUpdate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ onMenuUpdate(menu) { const next = { ...(this.manifest || {}), menu } this.depthError = false this.emitManifest(next) }, + /** + * Observed behaviour of `onDepthViolation` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ onDepthViolation() { this.depthError = true }, + /** + * Observed behaviour of `onConfigUpdate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ onConfigUpdate(config) { if (this.selectedIndex < 0) { return @@ -294,10 +384,20 @@ export default { const next = { ...(this.manifest || {}), pages } this.emitManifest(next) }, + /** + * Observed behaviour of `saveAndPreview` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ saveAndPreview() { this.$emit('save-and-preview') }, // --- Undo / redo (OQ-1) ------------------------------------------- + /** + * Observed behaviour of `undo` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ undo() { if (!this.history) { return @@ -307,6 +407,11 @@ export default { this.emitManifest(prev) } }, + /** + * Observed behaviour of `redo` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ redo() { if (!this.history) { return @@ -316,6 +421,11 @@ export default { this.emitManifest(next) } }, + /** + * Observed behaviour of `onKeydown` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ onKeydown(event) { if (!event || !(event.ctrlKey || event.metaKey)) { return @@ -330,24 +440,44 @@ export default { } }, // --- Inline validator marks (task 5.5) ---------------------------- + /** + * Observed behaviour of `configPathPrefix` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ configPathPrefix(configKey) { if (this.selectedIndex < 0) { return '' } return `/pages/${this.selectedIndex}/config/${configKey}` }, + /** + * Observed behaviour of `registerConfigField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ registerConfigField(configKey) { const prefix = this.configPathPrefix(configKey) if (prefix && this.validator && typeof this.validator.register === 'function') { this.validator.register(prefix) } }, + /** + * Observed behaviour of `unregisterConfigField` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ unregisterConfigField(configKey) { const prefix = this.configPathPrefix(configKey) if (prefix && this.validator && typeof this.validator.unregister === 'function') { this.validator.unregister(prefix) } }, + /** + * Observed behaviour of `configErrorFor` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-1 + */ configErrorFor(configKey) { const empty = { hasError: false, message: '' } if (!this.validator || typeof this.validator.errorFor !== 'function') { diff --git a/src/views/PageDesignerHost.vue b/src/views/PageDesignerHost.vue index a0a9b08a..a55a5054 100644 --- a/src/views/PageDesignerHost.vue +++ b/src/views/PageDesignerHost.vue @@ -109,6 +109,7 @@ export default { * The virtual-app slug from the route (/builder/:slug/pages). * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ routeSlug() { return this.$route.params.slug || '' @@ -119,6 +120,7 @@ export default { * The underscore-prefix form avoids colliding with user-defined `?version=` params. * * @return {string|undefined} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ versionSlug() { return this.$route.query._version || undefined @@ -130,6 +132,7 @@ export default { * identical for both "doesn't exist" and "you can't see it" cases. * * @return {boolean} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ versionNotFound() { return !this.versionLoading && this.versionError !== null && this.applicationVersion === null @@ -139,6 +142,7 @@ export default { * The Application's canonical UUID (OR puts it at @self.id). * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ applicationUuid() { const self = this.application && this.application['@self'] @@ -149,6 +153,7 @@ export default { * Full-page link into the virtual app, if it has ever been published. * * @return {string} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ builderUrl() { if (!this.application) { @@ -160,15 +165,30 @@ export default { }, watch: { + /** + * Observed behaviour of `routeSlug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ routeSlug() { this.resolveVersion() this.load() }, + /** + * Observed behaviour of `versionSlug` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ versionSlug() { this.resolveVersion() }, }, + /** + * Observed behaviour of `created` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 + */ created() { // REQ-OBVR-004: resolve the active ApplicationVersion on created. // NOTE: we do NOT call $router.replace() or $router.push() here — that @@ -183,6 +203,7 @@ export default { * (REQ-OBVR-004 / REQ-OBVR-005). Called on created and when slug/versionSlug change. * * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ resolveVersion() { this.versionError = null @@ -209,6 +230,7 @@ export default { * Fetch the Application for the current slug and seed the editor manifest. * * @return {Promise} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ async load() { this.loading = true @@ -236,6 +258,7 @@ export default { * * @param {object} next The new manifest. * @return {void} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ onManifestUpdate(next) { this.manifest = next @@ -245,6 +268,7 @@ export default { * Persist the edited manifest onto the Application object. * * @return {Promise} + * @spec openspec/changes/retrofit-2026-05-26-page-designer-ui/tasks.md#task-2 */ async save() { if (!this.application || !this.applicationUuid || this.saving) { diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue index f070c2d5..528d4741 100644 --- a/src/views/TemplateGallery.vue +++ b/src/views/TemplateGallery.vue @@ -101,12 +101,22 @@ export default { } }, computed: { + /** + * Observed behaviour of `categoryOptions` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ categoryOptions() { return Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ id: value, label: t('openbuilt', label), })) }, + /** + * Observed behaviour of `filteredTemplates` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ filteredTemplates() { const needle = this.search.trim().toLowerCase() const cat = this.categoryFilter?.id ?? this.categoryFilter ?? null @@ -128,6 +138,11 @@ export default { this.fetchTemplates() }, methods: { + /** + * Observed behaviour of `fetchTemplates` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ async fetchTemplates() { this.loading = true try { @@ -144,6 +159,11 @@ export default { this.loading = false } }, + /** + * Observed behaviour of `resolveScreenshot` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ resolveScreenshot(url) { if (!url) { return '' @@ -153,13 +173,28 @@ export default { } return generateUrl(`/apps/openbuilt/${url}`) }, + /** + * Observed behaviour of `categoryLabel` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ categoryLabel(category) { return t('openbuilt', CATEGORY_LABELS[category] || category || '') }, + /** + * Observed behaviour of `openClone` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ openClone(template) { this.cloneTarget = template this.cloneOpen = true }, + /** + * Observed behaviour of `onCloneSubmit` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ async onCloneSubmit(payload) { const slug = this.cloneTarget?.slug if (!slug) { @@ -176,6 +211,11 @@ export default { this.$refs.cloneDialog?.setError(message) } }, + /** + * Observed behaviour of `redirectAfterClone` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-template-catalogue-ui/tasks.md#task-1 + */ redirectAfterClone(created) { const slug = created?.slug if (!slug) { diff --git a/src/views/VersionHistory.vue b/src/views/VersionHistory.vue index 12e131be..a4342001 100644 --- a/src/views/VersionHistory.vue +++ b/src/views/VersionHistory.vue @@ -82,6 +82,11 @@ export default { watch: { applicationUuid: { immediate: true, + /** + * Observed behaviour of `handler` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ handler(uuid) { if (uuid) { this.refresh() @@ -92,6 +97,11 @@ export default { }, }, methods: { + /** + * Observed behaviour of `refresh` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ async refresh() { if (!this.applicationUuid) { this.versions = [] @@ -122,31 +132,71 @@ export default { this.loading = false } }, + /** + * Observed behaviour of `rowKey` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowKey(row) { return this.rowUuid(row) || JSON.stringify(row).slice(0, 32) }, + /** + * Observed behaviour of `rowUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowUuid(row) { const self = (row && row['@self']) || {} return self.id || self.uuid || row.uuid || '' }, + /** + * Observed behaviour of `rowApplicationUuid` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowApplicationUuid(row) { return (row && row.applicationUuid) || '' }, + /** + * Observed behaviour of `rowVersion` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowVersion(row) { return (row && row.version) || '' }, + /** + * Observed behaviour of `rowPublishedAt` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowPublishedAt(row) { return (row && row.publishedAt) || '' }, + /** + * Observed behaviour of `rowPublishedBy` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowPublishedBy(row) { return (row && row.publishedBy) || '' }, + /** + * Observed behaviour of `rowNotes` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ rowNotes(row) { return (row && row.notes) || '' }, isCurrent(row) { return this.rowUuid(row) === this.currentVersionUuid }, + /** + * Observed behaviour of `formatDate` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ formatDate(iso) { if (!iso) { return '' @@ -157,9 +207,19 @@ export default { return iso } }, + /** + * Observed behaviour of `compare` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ compare(row) { this.$emit('compare', { from: 'draft', to: this.rowUuid(row) }) }, + /** + * Observed behaviour of `askRollback` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ askRollback(row) { this.rollbackTarget = { uuid: this.rowUuid(row), @@ -169,11 +229,21 @@ export default { } this.rollbackOpen = true }, + /** + * Observed behaviour of `onRollbackConfirmed` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ onRollbackConfirmed(version) { this.$emit('rollback', version) this.rollbackOpen = false this.rollbackTarget = null }, + /** + * Observed behaviour of `onRollbackCancelled` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-version-routing-ui/tasks.md#task-1 + */ onRollbackCancelled() { this.rollbackOpen = false this.rollbackTarget = null diff --git a/src/views/settings/AdminRoot.vue b/src/views/settings/AdminRoot.vue index f21f6751..ced376de 100644 --- a/src/views/settings/AdminRoot.vue +++ b/src/views/settings/AdminRoot.vue @@ -40,6 +40,11 @@ export default { appVersion: loadState('openbuilt', 'version', 'Unknown'), } }, + /** + * Observed behaviour of `created` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ async created() { await initializeStores() this.storesReady = true diff --git a/src/views/settings/Settings.vue b/src/views/settings/Settings.vue index 2b70c224..7d0f87df 100644 --- a/src/views/settings/Settings.vue +++ b/src/views/settings/Settings.vue @@ -47,11 +47,21 @@ export default { successMessage: '', } }, + /** + * Observed behaviour of `created` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ created() { const settingsStore = useSettingsStore() this.form.register = settingsStore.settings?.register || '' }, methods: { + /** + * Observed behaviour of `save` (retrofit annotation). + * + * @spec openspec/changes/retrofit-2026-05-26-frontend-foundation/tasks.md#task-4 + */ async save() { this.saving = true this.successMessage = ''