diff --git a/README.md b/README.md
index 9795da66..c366e972 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,15 @@
-
+
---
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:
+
+
+
+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 `