diff --git a/README.md b/README.md index 9795da66..c366e972 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,15 @@ Latest release License Code quality - Documentation + Documentation

--- MyDash supercharges the Nextcloud dashboard. Create multiple personalized workspaces with drag-and-drop widgets, custom shortcut tiles, and smart visibility rules — then let admins roll out templates to entire teams. It works with every existing Nextcloud dashboard widget out of the box, supporting both the v1 and v2 Dashboard APIs. +📚 **[Step-by-step tutorials](https://mydash.conduction.nl/docs/category/tutorials)** — user + admin walkthroughs with screenshots, kept in sync with the live UI via the [journeydoc](https://github.com/ConductionNL/hydra/blob/development/openspec/architecture/adr-030-journeydoc-pattern.md) capture spec. + ## Screenshots diff --git a/docs/features/dashboard-deeplinking.md b/docs/features/dashboard-deeplinking.md new file mode 100644 index 00000000..fd2def7a --- /dev/null +++ b/docs/features/dashboard-deeplinking.md @@ -0,0 +1,91 @@ +# Dashboard deep-linking + +Each dashboard has its own URL — paste, bookmark, or share it. Visit +`/apps/mydash/{slug-chain}` and the workspace opens directly on that +dashboard. Switching dashboards in the sidebar updates the URL via +`pushState`; back/forward navigates between dashboards. + +## URL format + +``` +/apps/mydash/ → resolver default +/apps/mydash/finance → top-level slug +/apps/mydash/finance/q1-roadmap → nested via slug-chain +/apps/mydash/finance/q1-roadmap/details → arbitrarily deep +``` + +Slugs use lowercase ASCII letters, digits, and dashes (per the +dashboards capability's REQ-DASH-024). Multi-segment chains reflect +parent → child hierarchy. + +## How it works + +### Inbound (URL → state) + +1. User visits `/apps/mydash/some-slug`. +2. PHP catch-all route `page#deepLink` matches and dispatches to + `PageController::deepLink($deepLink)`. +3. The controller resolves the slug-chain via + `DashboardTreeService::resolvePath()`. +4. If resolved AND the user can read the dashboard → uses it as + active. +5. If unresolved or no permission → silently falls back to the + seven-step resolver, logs a warning. **Never 404s** — old + bookmarks always land on something. +6. Server pushes `deepLinkPath` (the canonical slug-chain) into + initial state. +7. Frontend reads `deepLinkPath` and `replaceState`s the URL to the + canonical form. Stale URLs (parent renamed) get normalised + in-place without a reload. + +### Outbound (state → URL) + +1. User clicks a row in the sidebar. +2. `switchDashboard()` updates `activeDashboard.uuid`. +3. A watcher on `activeDashboard.uuid` fetches the canonical path + via `GET /api/dashboards/{uuid}/path`. +4. If non-empty → `history.pushState({uuid}, '', '/apps/mydash/' + path)`. +5. If empty (NULL slug — unaddressable dashboard) → no `pushState`. + +### Back / forward + +A `popstate` listener on `Views.vue` mount: + +1. Strips `/apps/mydash/` from `window.location.pathname`. +2. Calls `getDashboardByPath(path)` to resolve. +3. Calls `switchDashboard(uuid)`. + +Back / forward between dashboards then matches user expectation. + +## Why the catch-all is safe for `/api/...` + +The route registration: + +```php +['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET', + 'requirements' => ['deepLink' => '(?!api(?:/|$)).+']], +``` + +The negative-lookahead `(?!api(?:/|$))` excludes any path starting +with `api/` (or just `api`). The Newman integration suite asserts +`GET /api/health` returns 200 as a regression check. + +The catch-all MUST stay LAST in `appinfo/routes.php` so every literal +`/api/...` route matches first. + +## Endpoints + +| Method | Endpoint | Purpose | +|---|---|---| +| GET | `/api/dashboards/by-path/{path}` | Resolve slug-chain → dashboard envelope (requirements `path: .+`) | +| GET | `/api/dashboards/{uuid}/path` | Compute canonical slug-chain for a dashboard UUID | +| GET | `/{deepLink}` (catch-all) | Renders the workspace page with the dashboard pre-resolved | + +## Tutorials + +- [Bookmark or share a dashboard URL](/docs/tutorials/user/deep-link) + +## Spec reference + +[`openspec/specs/dashboard-deeplinking/spec.md`](../../openspec/specs/dashboard-deeplinking/spec.md) +— REQ-DDL-001 through REQ-DDL-007. diff --git a/docs/features/default-widget-bundle.md b/docs/features/default-widget-bundle.md new file mode 100644 index 00000000..8834dcc6 --- /dev/null +++ b/docs/features/default-widget-bundle.md @@ -0,0 +1,114 @@ +# Default widget bundle + +Every newly-created personal dashboard ships with four pre-configured +widgets so first-create users land on a non-empty grid. Plus a loading +shim that hides the empty-state CTA during the initial fetch. + +## What you get + +A new dashboard renders with this layout on a 12-column grid: + +| Position | Widget | Where it points | +|---|---|---| +| top-left (0–3, 0–2) | Conduction tile | https://conduction.nl | +| top-middle (4–7, 0–2) | Sendent tile | https://sendent.com | +| top-right (8–11, 0–2) | Nextcloud tile | https://nextcloud.com | +| bottom (0–11, 3–7) | Files widget | user's root folder | + +Users can immediately drag, resize, replace, or remove any of these. + +## When it fires + +| Path | Seeded? | +|---|---| +| Sidebar **+ Add dashboard** button (`POST /api/dashboard`) | ✅ yes | +| First-login bootstrap when no admin template applies | ✅ yes (via `createDefaultPlacements()` delegate) | +| First-login bootstrap when an admin template applies | ❌ no — template-defined widgets only | +| First-login bootstrap when role-defaults seed something | ❌ no — role-defaults take precedence | + +## How it's wired + +``` +POST /api/dashboard + → DashboardApiController::create() + → DashboardService::createDashboard(seedDefaults: true) + → DashboardService::seedDefaultWidgets($dashboardId) + → 4 × placementMapper->insert() + ← envelope: { dashboard, placements: [4 entries] } +``` + +The frontend store reads `response.data.placements ?? []` and +populates `widgetPlacements` directly — no second round-trip. + +## Why `tileType='preset'` + +Each tile has `tileType='preset'`. This is required for +[`WidgetPlacement::jsonSerialize()`](../../lib/Db/WidgetPlacement.php) +to emit the flat `tile*` fields the renderer reads. The `'preset'` +sentinel is intentionally distinct from the legacy `'custom'` value +used by the deprecated `oc_mydash_tiles` table — `'custom'` routes +through the pre-registry tile path in `DashboardGrid.vue`, while +`'preset'` keeps the placements on the registry-backed `TileWidget` +renderer. + +## Loading shim + +The empty-state CTA ("No dashboard yet" / Create dashboard) used to +flash during the initial fetch because `activeDashboard` is null +until `loadDashboards()` resolves. [Views.vue](../../src/views/Views.vue) +now renders `NcLoadingIcon` while `loading=true` and +`activeDashboard` is null: + +```html + +
+ +
+
+ +
+``` + +The empty-state still renders for the legitimate "no dashboard +exists yet" case (e.g. personal-dashboards disabled by admin AND no +group default). + +## Tutorials + +- [Open MyDash for the first time](/docs/tutorials/user/first-launch) +- [Create a new dashboard](/docs/tutorials/user/create-dashboard) + +## Spec reference + +[`openspec/specs/default-widget-bundle/spec.md`](../../openspec/specs/default-widget-bundle/spec.md) +— REQ-DWB-001 through REQ-DWB-006. + +## API endpoint shape + +```http +POST /api/dashboard +Content-Type: application/json + +{ + "name": "My Dashboard", + "description": "Optional", + "icon": null +} + +→ 201 Created +{ + "data": { + "dashboard": { "id": …, "uuid": …, "name": "My Dashboard", … }, + "placements": [ + { + "widgetId": "tile", + "tileType": "preset", + "tileTitle": "Conduction", + "tileLinkValue": "https://conduction.nl", + "gridX": 0, "gridY": 0, "gridWidth": 4, "gridHeight": 3 + }, + … (3 more) + ] + } +} +``` diff --git a/docs/features/effective-default-marker.md b/docs/features/effective-default-marker.md new file mode 100644 index 00000000..552bf926 --- /dev/null +++ b/docs/features/effective-default-marker.md @@ -0,0 +1,67 @@ +# Effective default marker + +The dashboard switcher sidebar marks the user's *effective default +dashboard* — the one MyDash opens automatically when they visit +`/apps/mydash/` cold — with a small ★ icon and a hover tooltip. + +## What you see + +A star icon between the dashboard's own icon and its label, on +whichever sidebar row is the user's effective default: + +![Sidebar with star marker](/screenshots/tutorials/user/07-default-marker.png) + +The marker carries: + +- `title="Default dashboard — opens automatically when you visit MyDash"` — browser tooltip on hover +- `aria-label="Default dashboard"` — for screen readers +- `color: var(--color-warning)` — picks up Nextcloud theme overrides + +## "Effective default" — the chain + +The marker mirrors the resolver's first five steps (REQ-DASH-018): + +``` +1. defaultUuid (the user's pin) ← always wins +2. primary-group row with isDefault=1 ← admin-set group default +3. default-group row with isDefault=1 ← org-wide group default +4. first primary-group row ← when no flag +5. first default-group row ← when no flag +6. first personal dashboard ← intentionally NOT marked +``` + +Step 6 is excluded by design — silently starring an arbitrary +personal dashboard the user never marked is more confusing than +helpful. If no pin and no group default applies, no marker renders. + +## Setting / clearing the pin + +1. Open the sidebar. +2. Click the cog (⚙️) on the row you want as your default. +3. Click **Set as default**. +4. The marker appears next to that row immediately. + +To clear: same flow, the menu entry now reads **Default dashboard** +with a filled-star icon. Clicking it again clears the pin. + +After clearing, the marker either disappears (if no group default +applies) or moves to the resolver's group fallback. + +## Where in the code + +| What | File | +|---|---| +| `effectiveDefaultUuid` computed | [DashboardSwitcherSidebar.vue](../../src/components/Workspace/DashboardSwitcherSidebar.vue) | +| `isDefaultDashboard()` helper | same file | +| `` rendering | same file (3 row templates: primary-group, default-group, personal) | +| Pin persistence (server) | `setDefaultDashboardPreference()` in `dashboard.js` store | +| Pin endpoint | `POST /api/dashboards/default` | + +## Tutorials + +- [Set a default dashboard](/docs/tutorials/user/set-default) + +## Spec reference + +[`openspec/specs/effective-default-marker/spec.md`](../../openspec/specs/effective-default-marker/spec.md) +— REQ-EDM-001 through REQ-EDM-005. diff --git a/openspec/specs/dashboard-deeplinking/spec.md b/openspec/specs/dashboard-deeplinking/spec.md new file mode 100644 index 00000000..fe4012cd --- /dev/null +++ b/openspec/specs/dashboard-deeplinking/spec.md @@ -0,0 +1,169 @@ +--- +capability: dashboard-deeplinking +status: implemented +--- + +# Dashboard Deep-Linking Specification + +## Purpose + +Each dashboard has a stable, addressable URL based on its +slug-chain. Visiting `/apps/mydash/{slug-chain}` lands the workspace +on the matching dashboard; switching dashboards via the sidebar +pushes a new history entry; the browser back/forward buttons +navigate between dashboards. + +Before this capability, dashboards were addressable only by the +in-memory `activeDashboard` state — no URL bookmarking, no sharing +a link with a colleague, no back-button between dashboards. + +## Context + +The implementation is **bidirectional**: URL → state on cold load, +and state → URL on every sidebar switch. A catch-all PHP route +captures the slug-chain and the controller resolves it through +`DashboardTreeService::resolvePath()`. Stale slugs fall back +silently to the resolver's seven-step default rather than 404'ing +— old bookmarks always land on something. + +The frontend reads `deepLinkPath` from initial state (server has +already pre-resolved the active dashboard) and watches +`activeDashboard.uuid` to push history. A `popstate` listener +handles back/forward by calling `getDashboardByPath()` and +switching the active dashboard. + +## URL format + +``` +/apps/mydash/ → resolver default +/apps/mydash/{slug} → top-level slug +/apps/mydash/{parent}/{child} → nested via slug-chain +/apps/mydash/{parent}/{child}/{grand} → arbitrarily deep +``` + +Slugs use lowercase ASCII letters, digits, and dashes (REQ-DASH-024). +Multi-segment slug-chains are joined with `/` and supported by the +existing `GET /api/dashboards/by-path/{path}` endpoint +(`'requirements' => ['path' => '.+']` allows slashes in the captured +segment). + +## Requirements + +### Requirement: REQ-DDL-001 Catch-all route registration + +`appinfo/routes.php` MUST register a route named `page#deepLink` +with URL `/{deepLink}` and `'requirements' => ['deepLink' => +'(?!api(?:/|$)).+']`. The negative-lookahead requirement excludes +`/api/...` requests so the catch-all never shadows API routes. + +The route MUST be the LAST entry in the route table so every +literal `/api/...` and explicit page route is matched first. + +### Requirement: REQ-DDL-002 Server-side resolution + +`PageController::deepLink(string $deepLink): TemplateResponse` +delegates to `index($deepLink)` which: + +1. If `$deepLink` is non-empty, calls + `DashboardTreeService::resolvePath(path: $deepLink)`. +2. If the path resolves to a dashboard, calls + `DashboardService::getDashboardForUser($dashboard->getId(), $userId)` + for permission-checked envelope. Uses that as the active dashboard. +3. If the path doesn't resolve OR the user lacks read access, falls + back to `DashboardService::resolveActiveDashboard()` (the + seven-step resolver) and logs a warning. **Never returns 404 for + a stale slug.** + +### Requirement: REQ-DDL-003 Canonical path round-trip + +After active dashboard resolution, `PageController::index()` MUST +compute the canonical slug-chain via +`DashboardTreeService::computePath(uuid)` and pass it through +initial state as `deepLinkPath`. + +The frontend reads `deepLinkPath` on mount and replaces the URL via +`history.replaceState()` to match the canonical form. A user +visiting `/apps/mydash/old-parent/child` after a parent rename gets +silently normalised to `/apps/mydash/new-parent/child` without a +reload. + +### Requirement: REQ-DDL-004 New API endpoint for outbound URL sync + +A new endpoint `GET /api/dashboards/{uuid}/path` MUST return the +canonical slug-chain for a dashboard UUID: + +```json +{ + "data": { + "path": "finance/q1-roadmap" + } +} +``` + +Implementation calls `DashboardService::findPlacements()`-adjacent +helper that wraps `DashboardTreeService::computePath()`. Empty +result is a valid response (`""`) for dashboards with NULL slugs — +those are unaddressable. + +### Requirement: REQ-DDL-005 Frontend `pushState` on switch + +`Views.vue` MUST watch `activeDashboard.uuid` and on every change: + +1. Fetch the canonical path via `api.getDashboardPath(uuid)`. +2. If the response path is non-empty, push history: + ```js + history.pushState({ uuid }, '', '/apps/mydash/' + path) + ``` +3. If empty path (NULL slug), skip the pushState — no addressable + URL exists, leaving the URL unchanged. + +The `pushState` MUST NOT fire on the initial mount (the URL is +already correct from the server-side render); it fires only on +subsequent transitions. + +### Requirement: REQ-DDL-006 Frontend `popstate` listener + +`Views.vue` MUST register a `popstate` listener on mount that: + +1. Reads `window.location.pathname`. +2. Strips the route prefix `/apps/mydash/` to get the slug-chain. +3. Calls `api.getDashboardByPath(path)` to resolve it server-side. +4. Calls `switchDashboard(uuid)` with the resolved UUID. + +Browser back / forward navigation between dashboards then matches +the user's expectation. Visiting any URL the user hasn't seen +before still works because step 3 hits the resolver. + +### Requirement: REQ-DDL-007 API regression check + +`tests/integration/mydash.postman_collection.json` MUST include a +regression check that `GET /api/health` still routes correctly +after the catch-all is registered. The negative-lookahead pattern +prevents API shadowing but a misconfiguration (e.g. moving the +catch-all above an API route) would silently break every API +endpoint. + +The catch-all route MUST come AFTER every `/api/...` entry in +`routes.php`. + +## Test coverage + +- `tests/Unit/Controller/DashboardApiControllerComputePathTest.php` — + 4 cases pinning the canonical-path API (auth, missing UUID, happy + path, empty path for NULL slug). +- `tests/integration/mydash.postman_collection.json` — Newman + asserts the deep-link flow end-to-end: + - `GET /apps/mydash/{slug}` returns 200 HTML + - `GET /apps/mydash/{stale-slug}` returns 200 (silent fallback, + not 404) + - `GET /api/health` returns 200 (regression: the catch-all + didn't shadow the API) + - `GET /api/dashboards/{uuid}/path` returns the canonical + slug-chain + +## References + +- Implementation: PR #131 (deep-linking). +- Slug uniqueness + tree structure: `REQ-DASH-023..029` in the + dashboards capability. +- Frontend reference: [docs/features/dashboard-deeplinking.md](../../../docs/features/dashboard-deeplinking.md). diff --git a/openspec/specs/default-widget-bundle/spec.md b/openspec/specs/default-widget-bundle/spec.md new file mode 100644 index 00000000..f02e854c --- /dev/null +++ b/openspec/specs/default-widget-bundle/spec.md @@ -0,0 +1,146 @@ +--- +capability: default-widget-bundle +status: implemented +--- + +# Default Widget Bundle Specification + +## Purpose + +Every newly-created personal dashboard ships with a preconfigured set +of four widget placements so the user lands on a non-empty grid. +Empty grids on first-create were a documented friction point — the +"No dashboard yet" empty-state CTA flashed on every cold load and new +dashboards rendered as a blank canvas with no way to discover the +widget catalog. + +This spec covers the seed bundle on user-initiated dashboard creation +plus the loading-shim that hides the empty-state during initial fetch. + +## Context + +Two related changes ship under one capability because they address +the same cold-load gap: + +1. **Seed bundle** — three preconfigured `tile` widgets + (Conduction, Sendent, Nextcloud) on the top row plus a `files` + widget below, applied on every user-initiated dashboard creation. +2. **Loading shim** — `Views.vue` renders `NcLoadingIcon` while the + store's `loading` flag is set and `activeDashboard` is null, + hiding the empty-state until `loadDashboards()` resolves. + +Admin-template dashboards intentionally bypass the seed (templates +ship the widget set their author intended) and the bootstrap +`tryCreateFromTemplate()` path keeps its own role-default-driven +seed. + +## Requirements + +### Requirement: REQ-DWB-001 Four widgets seeded on user-initiated create + +WHEN a user creates a new dashboard via `POST /api/dashboard` (e.g. +the sidebar's "+" button) +THEN the controller MUST call `DashboardService::createDashboard()` +with `seedDefaults: true` AND the service MUST insert exactly four +widget placements via the new private `seedDefaultWidgets()` helper: + +| Position | widgetId | tileType | tileTitle | Grid (x,y,w,h) | +|---|---|---|---|---| +| 0 | `tile` | `preset` | `Conduction` | (0, 0, 4, 3) | +| 1 | `tile` | `preset` | `Sendent` | (4, 0, 4, 3) | +| 2 | `tile` | `preset` | `Nextcloud` | (8, 0, 4, 3) | +| 3 | `files` | NULL | NULL | (0, 3, 12, 5) | + +Conduction tile MUST link to `https://conduction.nl` with the +Conduction PNG asset. Sendent tile MUST link to +`https://sendent.com` with the Sendent PNG asset. Nextcloud tile +MUST link to `https://nextcloud.com` with the `icon-nextcloud` CSS +class (`tileIconType: 'class'`). + +### Requirement: REQ-DWB-002 `tileType='preset'` is required for serialization + +`WidgetPlacement::jsonSerialize()` only emits the flat `tile*` +columns when `tileType !== null`. The seeded tiles MUST set +`tileType='preset'` so the renderer's flat-column path receives the +title / icon / link fields. + +The `'preset'` sentinel intentionally differs from the legacy +`'custom'` value used by `oc_mydash_tiles` placements — `'custom'` +routes through the pre-registry tile path in `DashboardGrid.vue`, +while `'preset'` keeps the placements on the registry-backed +`TileWidget` renderer (REQ-WDG-022). + +### Requirement: REQ-DWB-003 Bootstrap path keeps `seedDefaults: false` + +The first-login bootstrap (`DashboardService::tryCreateFromTemplate()`) +calls `createDashboard(..., seedDefaults: false)` because it has its +own role-default-driven seed via +`RoleFeaturePermissionService::seedLayoutFromRoleDefaults()`. The +bootstrap fallback `createDefaultPlacements()` is now a thin +delegate to `seedDefaultWidgets()` so first-login users get the same +four-widget bundle when no admin template applies. + +### Requirement: REQ-DWB-004 Admin-template path bypasses the seed + +WHEN an admin template applies (the +`DashboardResolver::handleTemplateResult()` path) +THEN the resolver MUST seed the dashboard with the template's +declared widget set, NOT the four-widget bundle. Template authors +own the layout; the default bundle is for users who didn't get an +admin template. + +### Requirement: REQ-DWB-005 Create response returns placements envelope + +`POST /api/dashboard` MUST return the same envelope shape as +`GET /api/dashboard` (`getActive`): + +```json +{ + "dashboard": { ... }, + "placements": [ ... ] +} +``` + +The frontend store reads `response.data.placements ?? []` and +populates `widgetPlacements` directly — no second round-trip to +fetch placements after create. Callers running against an older +backend that omits the `placements` key fall back to `[]` +gracefully. + +### Requirement: REQ-DWB-006 Loading shim hides empty-state during fetch + +WHEN `Views.vue` is rendered AND `activeDashboard` is null AND the +dashboard store's `loading` flag is true +THEN the component MUST render an `NcLoadingIcon` shim instead of +the "No dashboard yet" empty-state. + +Template precedence (top to bottom): + +``` +v-if="activeDashboard" → DashboardGrid +v-else-if="loading" → NcLoadingIcon (the shim) +v-else → NcEmptyContent (empty-state CTA) +``` + +This eliminates the flash where the empty-state rendered briefly +during the initial `loadDashboards()` call. + +## Test coverage + +- `tests/Unit/Service/DashboardServiceCreateDefaultsTest.php` — + asserts the four placements ship with the correct widgetId, + tileType, tileTitle, tileLinkValue, and grid coordinates. +- `src/views/__tests__/Views.loadingState.spec.js` — asserts the + loading shim renders when `loading=true` and the empty-state + renders when `loading=false`. +- `tests/integration/mydash.postman_collection.json` — Newman + pins the create-response envelope: `placements.length === 4` + with the per-tile shape and grid coordinates listed in + REQ-DWB-001 above. + +## References + +- Implementation: PR #129 (default widget bundle + loading shim). +- Tile-renderer flat-column compatibility: `REQ-WDG-022` and + `REQ-TILE-PLACEMENT` in the widgets capability. +- Frontend reference: [docs/features/default-widget-bundle.md](../../../docs/features/default-widget-bundle.md). diff --git a/openspec/specs/effective-default-marker/spec.md b/openspec/specs/effective-default-marker/spec.md new file mode 100644 index 00000000..363cc5b1 --- /dev/null +++ b/openspec/specs/effective-default-marker/spec.md @@ -0,0 +1,107 @@ +--- +capability: effective-default-marker +status: implemented +--- + +# Effective Default Marker Specification + +## Purpose + +The dashboard switcher sidebar marks the user's *effective default +dashboard* — the dashboard the resolver lands them on when they +visit `/apps/mydash/` cold — with a small ★ icon and a tooltip. + +Without the marker, the only feedback after clicking "Set as +default" in a row's cog menu was the StarCheck icon inside the menu +itself, which auto-closes after the click. Users couldn't tell at a +glance which dashboard was their default. + +## Context + +"Effective default" means the dashboard the seven-step resolver +chain (REQ-DASH-018) would pick at cold load. The first five steps +of the chain are sidebar-visible and therefore eligible for the +marker: + +1. **User pin** — `defaultDashboardUuid` set via + `setDefaultDashboardPreference()`. +2. **Primary-group default** — `groupDashboards` row with + `isDefault=1` AND `groupId == primaryGroup`. +3. **Default-group default** — `defaultGroupDashboards` row with + `isDefault=1`. +4. First primary-group dashboard (no flag). +5. First default-group dashboard (no flag). + +Step 6 (first personal dashboard) is intentionally NOT marked when +no explicit pin or group default applies — silently starring an +arbitrary personal dashboard the user never marked is more +confusing than helpful. + +## Requirements + +### Requirement: REQ-EDM-001 Star icon renders on the effective-default row + +WHEN `DashboardSwitcherSidebar.vue` renders any sidebar section +(primary-group / default-group / personal) +THEN each row MUST consult `isDefaultDashboard(dashboard)` AND +render a `` +containing a `Star` icon when the result is true. The marker +appears between the dashboard's own icon and its label so the +dashboard's icon stays anchored to the left edge. + +### Requirement: REQ-EDM-002 Effective default uses resolver precedence + +`isDefaultDashboard(d)` MUST compare `d.uuid` against +`effectiveDefaultUuid`, the computed property that mirrors the +resolver's first five steps: + +``` +1. defaultUuid (the user's pin) — when set +2. primaryGroupDashboards.find(d => d.isDefault === 1) — when present +3. defaultGroupDashboards.find(d => d.isDefault === 1) — when present +4. primaryGroupDashboards[0] — when at least one exists +5. defaultGroupDashboards[0] — when at least one exists +6. (no fallback to personal dashboards — intentional) +``` + +The pin always wins over group fallbacks. + +### Requirement: REQ-EDM-003 Tooltip + accessibility + +The marker span MUST carry a `title` attribute with the localised +copy *"Default dashboard — opens automatically when you visit +MyDash"* and an `aria-label` with the shorter *"Default dashboard"* +so screen readers announce the marker. + +### Requirement: REQ-EDM-004 Reactive on pin change + +The marker MUST update on every change to `defaultUuid` or the +`groupDashboards` / `userDashboards` props without a page reload. +Pinning a different dashboard via the cog menu MUST move the marker +in the same render tick. + +### Requirement: REQ-EDM-005 Color uses theme variable + +The marker's color MUST be `var(--color-warning, #e9a800)` so it +inherits Nextcloud theme overrides (light/dark variants). + +## Test coverage + +- `src/components/Workspace/__tests__/DashboardSwitcherSidebar.spec.js` + has 9 cases: + - Marker renders only on the matching row + - Marker carries `title` + `aria-label` + - No marker when `defaultUuid` is empty AND no group fallback applies + - Marker on a group-section row when the user pinned a group dashboard + - Falls back to default-group `isDefault=1` when no pin set + - Prefers primary-group `isDefault=1` over default-group `isDefault=1` + - Falls back to first primary-group row when no `isDefault=1` flag + - Does NOT star a personal dashboard via fallback + - Pin still wins over any group fallback + +## References + +- Implementation: PR #130 (initial marker) + PR #130 follow-up + (group-default fallback). +- Resolver chain: `REQ-DASH-018` in the dashboards capability. +- Frontend reference: [docs/features/effective-default-marker.md](../../../docs/features/effective-default-marker.md).