From efa59173e42297e42560fdc8492c1755c2f19e91 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 5 Aug 2026 00:03:33 +0200 Subject: [PATCH] refactor: move view selection to titlebar menu --- README.md | 4 + docs/INTERFACE_PATTERN_MIGRATION.md | 2 +- src/govoplan_views/backend/manifest.py | 6 +- .../test-interface-pattern-language.mjs | 2 + webui/src/components/ViewSelector.tsx | 107 ++++++++++++++---- webui/src/styles/views.css | 55 ++++----- 6 files changed, 125 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index d7c2ce5..1333c0f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Views are presentation filters, never security boundaries. Hidden routes still use their normal permission checks. Required Views retain the View selector and administration surfaces needed to inspect and change the assignment, preventing administrators from locking an installation out of its own configuration. +The selector is the titlebar eye button next to the language control. It opens +the available-View menu and uses the accent color whenever a specialized View +is active; the full interface keeps the icon neutral. The selector announces +the `views.selector` help context so `F1` opens the matching Views guidance. Views are also the canonical user/group module-visibility mechanism. Core creates a root `.module` surface for every WebUI module, so a View may diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index ee0aa02..3b6138e 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -9,7 +9,7 @@ assignments, effective selection, and the safeguards described here. | Surface | Archetype | Consequence class | Contract | | --- | --- | --- | --- | -| `views.selector` | Compact global selector | Change active presentation projection | Localized accessible selector, required-state explanation, unsaved-change guard | +| `views.selector` | Compact titlebar eye menu | Change active presentation projection | Neutral full-interface state, accented specialized state, localized accessible menu, required-state explanation, unsaved-change guard | | `views.admin.system` | Administration list-detail editor | Create, publish, archive, assign | Shared admin layout, explicit disabled reasons, required-View lockout validation, contextual help | | `views.admin.tenant` | Administration list-detail editor | Create, publish, archive, assign | Inheritance provenance, searchable group/user targets, explicit optional-module blocker | | `views.settings.personal` | Settings list-detail editor | Create and publish owned Views | Shared owner selector, permission blocker, contextual field help | diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index 0240328..13bb1b4 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -303,8 +303,10 @@ manifest = ModuleManifest( "and tenant administrators can publish Views and make them " "available, default, or required at system, tenant, group, and " "user scope. Required Views retain administration escape surfaces " - "so they can always be inspected and changed. Hidden functions " - "remain protected by their normal permission checks." + "so they can always be inspected and changed. The titlebar eye " + "opens the selector and is accented while a specialized View is " + "active. Hidden functions remain protected by their normal " + "permission checks." ), layer="available", documentation_types=("admin", "user"), diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index 0d9197a..bdf56e3 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -24,6 +24,8 @@ assert(admin.includes("LOCKOUT_SURFACES") && admin.includes("requiredMissing"), assert(patterns.includes('topicId: "views.interface-projections"') && patterns.includes('topicId: "views.reference.fields-and-consequences"'), "Views uses stable manifest-backed help references"); assert(moduleSource.includes("generatedTranslations") && moduleSource.includes('label: "i18n:govoplan-views.views"'), "Module and surface labels are localized"); assert(selector.includes("usePlatformLanguage") && selector.includes("required_by_administrator"), "The global selector localizes and explains its locked state"); +assert(selector.includes("View as ViewIcon") && selector.includes("is-context-active"), "The global selector uses the compact View icon and marks an effective projection"); +assert(selector.includes("useUnsavedChanges") && selector.includes("requestNavigation"), "View changes retain the shared dirty-navigation guard"); assert(translations.includes('"i18n:govoplan-views.views_administration_is_read_only"'), "Availability explanations are present in the translation catalogue"); console.log("Views surfaces satisfy the recorded interface pattern-language contract."); diff --git a/webui/src/components/ViewSelector.tsx b/webui/src/components/ViewSelector.tsx index 1416eee..9dc43bb 100644 --- a/webui/src/components/ViewSelector.tsx +++ b/webui/src/components/ViewSelector.tsx @@ -1,6 +1,7 @@ -import { Eye, LockKeyhole } from "lucide-react"; -import { useState } from "react"; +import { Check, LockKeyhole, View as ViewIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { + DismissibleAlert, dispatchPlatformViewChanged, usePlatformLanguage, useUnsavedChanges, @@ -15,10 +16,21 @@ export default function ViewSelector({ }: ViewSelectorProps) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); const { translateText } = usePlatformLanguage(); const { requestNavigation } = useUnsavedChanges(); const options = projection?.availableViews ?? []; + useEffect(() => { + function onPointerDown(event: MouseEvent) { + const target = event.target as Node; + if (menuRef.current && !menuRef.current.contains(target)) setOpen(false); + } + window.addEventListener("mousedown", onPointerDown); + return () => window.removeEventListener("mousedown", onPointerDown); + }, []); + if (!projection?.activeViewId && options.length === 0) return null; async function performSelect(viewId: string) { @@ -27,6 +39,7 @@ export default function ViewSelector({ try { await selectEffectiveView(settings, viewId || null); dispatchPlatformViewChanged(); + setOpen(false); } catch (caught) { setError( caught instanceof Error @@ -45,6 +58,8 @@ export default function ViewSelector({ } const locked = Boolean(projection?.locked); + const activeViewId = projection?.activeViewId ?? ""; + const specialized = projection?.projectionActive ?? Boolean(activeViewId); const diagnostic = projection?.diagnostics.find((item) => item.severity === "error") ?? projection?.diagnostics[0]; const title = translateText(error || diagnostic?.message || ( @@ -54,27 +69,75 @@ export default function ViewSelector({ )); return ( - +