From ffc0a94afebbe0478b5b8c204f9cfd1fc980c1ae Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 13:34:31 +0200 Subject: [PATCH] docs(quick-261022-imo): plan settings section panel scoped to category --- .../261022-imo-PLAN.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 .planning/quick/261022-imo-settings-section-panel-scoped-to-categor/261022-imo-PLAN.md diff --git a/.planning/quick/261022-imo-settings-section-panel-scoped-to-categor/261022-imo-PLAN.md b/.planning/quick/261022-imo-settings-section-panel-scoped-to-categor/261022-imo-PLAN.md new file mode 100644 index 00000000..28c8f54e --- /dev/null +++ b/.planning/quick/261022-imo-settings-section-panel-scoped-to-categor/261022-imo-PLAN.md @@ -0,0 +1,215 @@ +--- +phase: quick-261022-imo +plan: 01 +quick_id: 261022-imo +slug: settings-section-panel-scoped-to-categor +type: execute +mode: quick +wave: 1 +depends_on: [] +autonomous: true +requirements: + - IMO-01 # settings pages (singleton form or settings-link controller without own nav entry) get a section panel of the current category + - IMO-02 # panel hidden for a one-entry category, on /settings and on every non-settings page; never lists other categories + - IMO-03 # same look and collapse/expand behaviour as the plugin SectionPanel; --shell-offset follows it + - IMO-04 # vitest coverage of the panel (component + shell + state helper) + - IMO-05 # docs/backend/settings.md describes the panel (same commit as the code) + - IMO-06 # modules/boardwalk/dist rebuilt and check-admin-dist.sh green +files_modified: + # Task 1: helper, panel, shell wiring, docs, tracer test + - admin/src/state/useSettings.ts + - admin/src/components/shell/SettingsPanel.vue + - admin/src/components/shell/AppShell.vue + - admin/tests/shell/AppShell.test.ts + - docs/backend/settings.md + # Task 2: tests + - admin/tests/shell/SettingsPanel.test.ts + - admin/tests/shell/AppShell.test.ts + - admin/tests/state/useSettings.test.ts + # Task 3: embedded build + - modules/boardwalk/dist + +estimate: + tokens: 90000 + raw_tokens: 90000 + tasks: 3 + confidence: low # estimate-calibration: factor 1, sample_count 0 + +must_haves: + truths: + - "On /settings/mail (System category holding Mail and Search) the shell renders an aside[data-settings-panel] whose header reads the category label 'System' and whose links are Mail then Search (settingsGroups order), Mail carrying aria-current=\"page\", each href equal to settingsPath(entry)." + - "On /settings/branding (Appearance holds only Branding) no settings panel and no aside render; on /settings (index) no aside renders." + - "The panel never lists an entry of another category: on /settings/mail the Branding entry is absent." + - "On a settings-link controller route whose plugin has no navigation entry (e.g. /acme/lang/locales and its record route /acme/lang/locales/5 for a System link entry 'locales') the settings panel renders with Locales current; on a settings-link controller whose plugin HAS a navigation entry (/acme/demo/widgets) the plugin SectionPanel renders and the settings panel does not." + - "Collapse works exactly like the plugin panel: the panel's [data-sidebar-collapse] button hides it (sidebar state persisted under summer-admin.sidebar), the rail's [data-sidebar-expand] button appears and takes focus, clicking it brings the panel back and focuses [data-sidebar-collapse]; a narrow viewport hides it too." + - "--shell-offset on the shell root is calc(var(--spacing-rail) + var(--spacing-panel)) while the settings panel shows and var(--spacing-rail) while it is collapsed or absent." + - "modules/boardwalk/dist matches a fresh build of admin/ (scripts/check-admin-dist.sh exits 0)." + artifacts: + - path: admin/src/components/shell/SettingsPanel.vue + provides: "Category-scoped settings section panel (aside[data-settings-panel], links [data-settings-panel-item=])" + - path: admin/src/state/useSettings.ts + provides: "exported settingsSectionOf(current) and SettingsSection type" + - path: admin/src/components/shell/AppShell.vue + provides: "picks SectionPanel (plugin) or SettingsPanel (settings page); --shell-offset accounts for either" + - path: admin/tests/shell/SettingsPanel.test.ts + provides: "component tests of the settings panel" + - path: modules/boardwalk/dist + provides: "embedded admin build containing the panel" + key_links: + - from: admin/src/components/shell/AppShell.vue + to: admin/src/state/useSettings.ts + via: "settingsEntry / settingsLinkFor resolve the current entry; settingsSectionOf(current) yields the category group or null" + - from: admin/src/components/shell/SettingsPanel.vue + to: admin/src/components/shell/PluginRail.vue + via: "shared data-sidebar-collapse / data-sidebar-expand focus hand-off and useSidebar setCollapsed" +--- + + +Add a settings section panel to the admin SPA shell: on a settings page (a singleton settings form `/settings/{code}`, route `settings-form`, or an admin controller a settings link entry opens when that controller's plugin has no navigation entry of its own) the shell shows a 224px panel, styled and collapsing exactly like the plugin side menu, that lists the permitted settings entries of the CURRENT entry's category only, in settingsGroups order, with the current entry highlighted. One-entry categories, the /settings index and all other pages show no settings panel. + +Purpose: WinterCMS parity. On Administrators an admin switches to Roles (both System) without going back to /settings; a plugin with two settings forms in one category (e.g. a payment plugin) likewise. Switching category still goes through /settings. + +Output: SettingsPanel.vue, a settingsSectionOf helper in useSettings.ts, AppShell wiring, vitest coverage, the docs/backend/settings.md paragraph, and a rebuilt modules/boardwalk/dist. SPA only: no API, pact or Go change. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md +@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/STATE.md + +Source files the executor reads before editing (all small): +@admin/src/components/shell/AppShell.vue +@admin/src/components/shell/SectionPanel.vue +@admin/src/components/shell/PluginRail.vue +@admin/src/state/useSettings.ts +@admin/src/state/useSidebar.ts +@admin/tests/shell/AppShell.test.ts +@admin/tests/shell/SectionPanel.test.ts +@admin/tests/state/useSettings.test.ts +@admin/tests/fixtures/settings.json +@docs/backend/settings.md + +Facts established during planning (do not re-derive): +- Settings categories arrive already translated: cabana's navigation.go builds each SettingsEntry with Category set by translateKey(ctx, tr, item.Category), and SettingsIndexView renders group.category as is. So "category label (translated)" means rendering entry.category; no phrasebook lookup and no new phrase key in the SPA. +- settingsGroups (useSettings.ts) sorts entries by order then first-seen index and groups by category; the panel must reuse that order, not re-sort. +- SectionPanel.vue markup to mirror: aside with aria-label t('backend::lang.nav.sections') and classes "sticky top-0 flex h-screen w-panel shrink-0 flex-col gap-0.5 self-start overflow-y-auto border-r border-border bg-surface px-3 py-3.5"; header div "mb-3.5 flex h-10 items-center justify-between gap-2 pl-2" with a truncated 16px bold label and the collapse button (data-sidebar-collapse, aria-label/title t('backend::lang.nav.collapse'), PanelLeftClose icon size 18, classes "flex size-[30px] shrink-0 items-center justify-center rounded-tab text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"); links are RouterLinks with classes "flex h-nav items-center gap-3 rounded-control px-3 no-underline transition-colors duration-150 ease-out" plus "bg-sel font-semibold text-text" when current, otherwise "font-medium text-muted hover:bg-hover hover:text-text", an 18px icon (aria-hidden) and the label. Its collapse() calls setCollapsed(true) then focuses [data-sidebar-expand] on nextTick. +- PluginRail's expand button ([data-sidebar-expand]) renders whenever collapsed && !narrow, independent of which panel is in play, and on click calls setCollapsed(false) then focuses [data-sidebar-collapse]. It therefore already works for the settings panel as long as SettingsPanel's collapse button carries data-sidebar-collapse. No PluginRail change is needed. +- SectionFlyout is per plugin rail item (opens on hover/focus of a plugin item while collapsed). The rail's Settings item is a plain link to /settings with no flyout. Decision for this task (Claude's discretion within the locked decisions): no settings flyout. Reason: a flyout on the Settings rail item would either list every category (the locked decision says the panel never lists other categories) or only the current one (meaningless off a settings page); the Settings rail item already opens /settings, which lists every permitted page grouped by category, one click away, and is how an admin switches category. On narrow viewports (forced collapse, no expand button) that index is also how settings pages are switched. This reasoning goes into the SettingsPanel header comment. +- Test fixture (admin/tests/fixtures/settings.json, settingsFixture.list.data): System holds mail (order 10) and search (order 20); Appearance holds branding (order 5) alone. navigationFixture plugins are acme.demo (widgets, gadgets), acme.tools (hammers) and hidden; acme.lang has no navigation entry. The lang fixture is Polish: 'backend::lang.nav.sections' = Sekcje, 'backend::lang.nav.collapse' = Zwiń menu, 'backend::lang.nav.settings' = Ustawienia. +- vitest runs with cwd admin/ when invoked through npm --prefix admin; test file filters are relative to admin/. +- Do not run npm ci (shared node_modules). check-admin-dist.sh only runs it when admin/node_modules is missing. + + + + + + Task 1: Settings panel end to end (helper, component, shell wiring, docs) for /settings/mail + git status --porcelain -- admin modules/boardwalk/dist docs prints nothing (the 19.6.1 gap-closure session has committed its admin work); admin/node_modules exists. + admin/src/state/useSettings.ts, admin/src/components/shell/SettingsPanel.vue, admin/src/components/shell/AppShell.vue, admin/tests/shell/AppShell.test.ts, docs/backend/settings.md + +Implements the locked decisions (current-category-only panel, hidden for a one-entry category, same look and collapse as the plugin side menu, SPA only). + +1. admin/src/state/useSettings.ts: export an interface SettingsSection with fields category (string), current (SettingsEntry) and entries (SettingsEntry[]). Export a function settingsSectionOf(current: SettingsEntry | null): SettingsSection | null that returns null for null, finds the settingsGroups.value group whose category equals current.category, returns null when that group is missing or holds fewer than two entries, and otherwise returns the group's category, the given current entry and the group's entries array (settingsGroups order, nothing re-sorted, no entry of any other category). Add settingsSectionOf to the object useSettings() returns. Add a doc comment in the file's style (what it returns and why one entry yields null: nothing to switch to). + +2. admin/src/components/shell/SettingsPanel.vue (new): script setup lang ts, one prop section of type SettingsSection. Render the same markup and classes as SectionPanel.vue (see the context facts): an aside with aria-label t('backend::lang.nav.sections') plus a data-settings-panel attribute; the header label is section.category, falling back to t('backend::lang.nav.settings') when the category is an empty string; the collapse button keeps data-sidebar-collapse, the same aria-label/title, icon and click handler behaviour (setCollapsed(true) imported from state/useSidebar, then focus [data-sidebar-expand] on nextTick). Below it one RouterLink per section.entries item: to settingsPath(entry), key entry.code, a data-settings-panel-item attribute set to entry.code, aria-current "page" when entry.code equals section.current.code (otherwise undefined), current/non-current classes as in SectionPanel, the icon from iconFor(entry.icon) at size 18 with aria-hidden, and entry.label as text. Labels go through text interpolation only. Head the script with a comment block in the SectionPanel style: what the panel is (settings section panel, level 2 of the shell, same look and collapse as the plugin side menu), that it lists only the current entry's category and switching category goes through /settings, that categories arrive translated from the server, and why there is no settings flyout on the rail (the reasoning from the context facts). + +3. admin/src/components/shell/AppShell.vue: import SettingsPanel, controllerIdFromPath (app/controllerRoutes) and settingsEntry, settingsLinkFor, settingsSectionOf (state/useSettings). Add a computed settingsSection: null when active (the plugin navigation entry) is set, because a settings-link controller with its own navigation entry keeps its plugin panel exactly as PluginRail's inSettings rule does; when route.name is 'settings-form' it is settingsSectionOf(settingsEntry(String(route.params.code ?? ''))) (Breadcrumbs parity); otherwise settingsSectionOf(settingsLinkFor(controllerIdFromPath(route.path))). The /settings index resolves to null through that last branch (no controller id). Make shellOffset use the panel width when (active or settingsSection) is set and the sidebar is not collapsed. In the template keep the SectionPanel line and add SettingsPanel right after it under v-else-if, shown when settingsSection is set and not collapsed, passing section. Update the shell's header comment to say the section panel is the active plugin's side menu or, on a settings page, the settings panel of its category. + +4. admin/tests/shell/AppShell.test.ts (tracer check): import setSettings and settingsFixture; add a describe block "settings section panel" with one case: setSettings(settingsFixture.list.data), mount the shell at /settings/mail, expect aside[data-settings-panel] to exist with header text System, the [data-settings-panel-item] links to read Mail then Search with hrefs /settings/mail and /settings/search, Mail with aria-current page and Search without. Task 2 adds the remaining cases to the same block. + +5. docs/backend/settings.md (same commit as the code, per the locked decision): in the "Linking a settings entry to an admin controller" section, extend the last paragraph (the one starting "In the admin, the entry's card on the Settings page") so it also says the section panel lists the settings pages of the entry's category. Then add a short section titled "## Moving between settings pages" right before "## How values are stored", in the page's plain prose style: on a settings form and on a linked controller that belongs to Settings, a panel next to the rail lists the settings pages of the same category in settings-index order, with the open page highlighted; it does not appear when the category has a single page, on the Settings page itself, or on a linked controller that has its own main navigation item (that controller keeps its plugin's side menu); another category is reached through the Settings page; the panel collapses and expands like a plugin's side menu and hides on narrow windows. No code identifiers that do not exist in a package (the docs checker enforces backticked identifiers); plain prose needs none. + +Commit (stage files by path, never git add -A): feat(quick-261022-imo): settings section panel scoped to the current category. No co-author tag. + + + npm --prefix admin run test -- tests/shell/AppShell.test.ts tests/state/useSettings.test.ts tests/shell/SectionPanel.test.ts tests/shell/PluginRail.test.ts tests/shell/Breadcrumbs.test.ts && npm --prefix admin run typecheck && go run ./cmd/summer docs:build --check && go test ./cmd/summer -count=1 -run '^TestDocsTree$' + + /settings/mail renders the System settings panel with Mail current and Search linked; all existing shell and settings tests still pass; vue-tsc is clean; the docs checker and TestDocsTree pass; one feat commit holds the five files. + + + + Task 2: Vitest coverage for the settings panel (component, shell, helper) + admin/tests/shell/SettingsPanel.test.ts, admin/tests/shell/AppShell.test.ts, admin/tests/state/useSettings.test.ts + + - settingsSectionOf(null) is null; for branding (alone in Appearance) it is null; for mail it is category System with entries [mail, search] in that order and current mail; a System link entry with order 15 lands between mail and search; no Appearance entry ever appears in a System section; useSettings().settingsSectionOf is the exported function. + - SettingsPanel component: aside labelled Sekcje with data-settings-panel; header shows the category; links in section order with hrefs from settingsPath (a link entry with controller acme.lang.locales links to /acme/lang/locales); only the current entry has aria-current page; an empty category shows Ustawienia in the header; clicking [data-sidebar-collapse] (aria-label Zwiń menu, type button) sets sidebarCollapsed true and stores 'true' under SIDEBAR_KEY. + - Shell: /settings/branding renders no aside; /settings renders no aside; /settings/mail never lists branding; with a System link entry locales (controller acme.lang.locales), /acme/lang/locales and /acme/lang/locales/5 render the settings panel with Locales current; with a link entry widgets (controller acme.demo.widgets), /acme/demo/widgets renders the plugin panel (header Demo) and no [data-settings-panel]; setCollapsed(true) hides the settings panel and --shell-offset becomes var(--spacing-rail), setCollapsed(false) restores it and the offset returns to calc(var(--spacing-rail) + var(--spacing-panel)); a narrow viewport (fakeMedia NARROW_QUERY true) hides it; clicking the panel's [data-sidebar-collapse] leaves the rail's [data-sidebar-expand] focused, and clicking that brings the panel back with [data-sidebar-collapse] focused. + + +Covers the locked test list (shown with 2+ entries in category, hidden with 1, current highlighted, only current category listed, hidden on /settings index, shown on a settings-link controller route, collapse respected) plus the rail expand hand-off decided in Task 1. + +1. admin/tests/state/useSettings.test.ts: add an it block per behavior bullet 1, importing settingsSectionOf; build link entries by spreading settingsFixture.list.data[0] with code, label, model '' and controller overridden, as the existing link tests do, and set order explicitly. + +2. admin/tests/shell/SettingsPanel.test.ts (new): follow SectionPanel.test.ts structure (beforeEach resetState and setCollapsed(false), afterEach setCollapsed(false) and clear document.body, enableAutoUnmount, a mountPanel helper that signs in, builds routerAt(path) and mounts SettingsPanel with a section prop attached to document.body). Build sections with setSettings(...) then settingsSectionOf(settingsEntry(code)) or settingsLinkFor(...) rather than hand-made objects, so the component is exercised with real helper output; for the empty-category case build the section object directly from fixture entries with category ''. Cover behavior bullet 2. + +3. admin/tests/shell/AppShell.test.ts: extend the "settings section panel" describe block from Task 1 with behavior bullet 3. Call setSettings before mounting (resetState clears settings in beforeEach). For the narrow case reuse fakeMedia and NARROW_QUERY as the existing viewport test does (fakeMedia before mount). For the focus hand-off, mount with attachTo document.body (mountShell already does), trigger click on the panel's [data-sidebar-collapse], flushPromises, assert document.activeElement matches [data-sidebar-expand], then click it, flushPromises, assert aside[data-settings-panel] exists again and document.activeElement matches [data-sidebar-collapse]. + +Run the whole admin suite once at the end, not only the touched files. Commit test files by path: test(quick-261022-imo): settings section panel coverage. No co-author tag. + + + npm --prefix admin run test -- tests/shell/SettingsPanel.test.ts tests/shell/AppShell.test.ts tests/state/useSettings.test.ts && npm --prefix admin run test && npm --prefix admin run typecheck + + Every behavior bullet has a passing test; the full admin vitest suite and vue-tsc pass; one test commit holds the three test files. + + + + Task 3: Rebuild the embedded admin dist on top of the current master + Tasks 1 and 2 are committed; git status --porcelain -- admin modules/boardwalk/dist prints nothing (no other session has uncommitted admin or dist edits; if one does, stop and report instead of building over it); admin/node_modules exists (never run npm ci). + modules/boardwalk/dist + +Per the locked decision, the committed build must contain the panel and pass the drift gate; this runs last so it builds on top of whatever the 19.6.1 gap-closure session committed. + +1. Run npm --prefix admin run build (vue-tsc then vite build into modules/boardwalk/dist with emptyOutDir). +2. Run scripts/check-admin-dist.sh; it must report that modules/boardwalk/dist matches a fresh build. +3. Run go test -short ./modules/boardwalk so the embed still compiles and serves. +4. Stage only modules/boardwalk/dist by path (git add modules/boardwalk/dist, which also stages removed hashed assets) and commit: chore(quick-261022-imo): rebuild embedded admin dist. No co-author tag. Do not push or tag. + + + scripts/check-admin-dist.sh && go test -short ./modules/boardwalk && git status --porcelain -- modules/boardwalk/dist admin + + check-admin-dist.sh exits 0, boardwalk tests pass, the dist commit exists, and git status shows nothing left under admin/ or modules/boardwalk/dist. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| server -> SPA settings list | GET /settings is filtered server-side per administrator (permissions and controller access); the panel only re-presents that list | +| URL -> SPA route params | /settings/{code} and controller paths are user-editable | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-261022-imo-01 | Information disclosure | SettingsPanel.vue / settingsSectionOf | low | mitigate | Entries come only from the server-filtered settings state (settingsGroups); the panel has no other source, so it cannot advertise a page or controller the administrator may not open. Covered by the "only current category" and helper tests. | +| T-261022-imo-02 | Tampering | AppShell settingsSection from route params | low | mitigate | The route code and controller id are only used as exact-match lookups (settingsEntry, settingsLinkFor); an unknown value yields null and no panel; labels render through Vue text interpolation, never as HTML. | +| T-261022-imo-03 | Elevation of privilege | panel links | low | accept | Links only navigate; every page the link opens is authorised by the API as before. No client-side permission logic is added. | +| T-261022-imo-SC | Tampering | npm/pip/cargo installs | high | accept | No package is added or installed; npm ci is explicitly not run (shared node_modules); the build uses the committed lockfile's existing install. | + + + +- npm --prefix admin run test (whole suite) and npm --prefix admin run typecheck pass. +- go run ./cmd/summer docs:build --check and go test ./cmd/summer -run '^TestDocsTree$' pass. +- scripts/check-admin-dist.sh passes after the dist commit. +- Three commits in order: feat (code + docs + tracer test), test (coverage), chore (dist). No Go, pact or API files changed: git diff --stat over the three commits lists only admin/, docs/backend/settings.md and modules/boardwalk/dist. + + + +- On a singleton settings form or a settings-linked controller without its own navigation entry, an admin sees the other settings pages of the same category in a side panel and can switch between them directly; the current page is highlighted. +- No panel for a one-page category, on /settings, or on any non-settings page; the panel never shows another category. +- The panel looks, collapses, expands and offsets bottom save bars exactly like the plugin side menu. +- Tests cover every listed case; docs describe the behaviour; the embedded dist is current. + + + +Create `.planning/quick/261022-imo-settings-section-panel-scoped-to-categor/261022-imo-SUMMARY.md` when done + + +