10 Commits
Author SHA1 Message Date
zemion 6e48c04af8 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:04:03 +02:00
zemion c8059c8afc fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:10 +02:00
zemion 3d33d7a217 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:40 +02:00
zemion ac2f47bd7a docs: complete public documentation baseline
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 07:23:31 +02:00
zemion eaca78803d feat(quick-access): add governed DSAR coverage 2026-08-21 03:54:39 +02:00
zemion 127b6ee5d0 feat(quick-access): expose effective preference provenance 2026-08-20 02:39:41 +02:00
zemion b5b629b357 feat(webui): add View focus escape to Quick Access 2026-08-19 20:14:02 +02:00
zemion 5a906185a6 feat: govern task-local quick actions 2026-08-19 18:47:46 +02:00
zemion d44ed78e5c feat: preserve full-page launch context 2026-08-18 21:32:25 +02:00
zemion da29de71e5 Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
20 changed files with 1585 additions and 42 deletions
+18
View File
@@ -13,3 +13,21 @@ Mail, Postbox, and future chat providers are composed inside the one Messages
overlay while retaining their independent delivery and authority semantics.
See [Quick Access architecture](docs/QUICK_ACCESS.md).
## Git-source WebUI package
The repository root exposes `@govoplan/quick-access-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/quick-access-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+26 -2
View File
@@ -25,6 +25,12 @@ override that decision, but may configure any still-available item. Stale
preferences are retained and diagnosed so uninstalling and reinstalling a
contributing module does not silently discard a user's arrangement.
The effective API reports each item's `availability_state`,
`availability_source`, and `order_source`. It also returns every unavailable
stored id as a `stale_preferences` entry with its system, tenant, or user
source. Administration and support tooling can therefore explain a result
without reading or copying any contributing module's state.
## Categories
The initial stable categories are Work, Calendar, Messages, and Files. Messages
@@ -37,5 +43,23 @@ owner module.
Quick Access is not an authorization boundary. Every contribution keeps its
own permission requirements and View surface. Full-page routes remain the
canonical fallback. Disabling this module removes the rail without making any
domain state unavailable through its owning module.
canonical fallback. Launch-context version 2 contains only the tenant/account
identity, a reference-contract-version-1 active object, acting-assignment
identifiers, temporal selection, exact View identity, View recommendations or
focus, and a safe return route. Cross-tenant object references are discarded
and unknown versions fail closed. View focus applies only when at least one
focused tool is currently enabled, context-compatible, and authorized; a
recommendation only changes ordering and emphasis. The rail identifies an
active View focus and offers **All available tools** as a temporary escape to
the complete permission-derived catalogue. This does not change the active
View, persist an override, or expose a tool that failed installation,
entitlement, policy, preference, context, surface, or permission checks. If no
focused tool is eligible, the rail explains that stale focus and falls back to
the permission-derived catalogue instead of becoming empty.
Each renderer performs owner-side reads and effects and explicitly reports
either a result-contract-version-1 completion with a typed owner reference or
a cancellation reason. Closing the drawer is not completion. The host may
listen for that correlated result while its unsaved page state remains mounted.
Disabling this module removes the rail without making any domain state
unavailable through its owning module.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/quick-access-webui",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/quick-access.css": "./webui/src/styles/quick-access.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-quick-access"
version = "0.1.18"
version = "0.1.21"
description = "Governed configurable Quick Access rail for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Quick Access module."""
__version__ = "0.1.18"
__version__ = "0.1.21"
@@ -0,0 +1,417 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_quick_access.backend.db.models import QuickAccessProfile
QUICK_ACCESS_DSAR_CAPABILITY = dsar_capability_name("quick_access")
_MAX_PREFERENCES = 1_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str
profile_id: str | None
class QuickAccessDsarProvider:
provider_id = "quick_access"
module_id = "quick_access"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
personal_query = db.query(QuickAccessProfile).filter(
QuickAccessProfile.scope_type == "user",
QuickAccessProfile.tenant_id == tenant_id,
QuickAccessProfile.scope_id == selectors.account_id,
)
attribution_query = db.query(QuickAccessProfile).filter(
QuickAccessProfile.scope_type == "tenant",
QuickAccessProfile.tenant_id == tenant_id,
or_(
QuickAccessProfile.created_by == selectors.account_id,
QuickAccessProfile.updated_by == selectors.account_id,
),
)
if selectors.profile_id:
personal_query = personal_query.filter(
QuickAccessProfile.id == selectors.profile_id
)
attribution_query = attribution_query.filter(
QuickAccessProfile.id == selectors.profile_id
)
records = [_personal_record(row) for row in personal_query.all()]
records.extend(
_attribution_record(row, selectors.account_id)
for row in attribution_query.all()
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Quick Access DSAR requires one corroborated account.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type == "quick_access_tenant_attribution":
actions.append(
DsarErasureActionRef(
action_id=(
"quick_access:retain:quick_access_tenant_attribution:"
f"{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Tenant Quick Access policy attribution is accountability evidence.",
executable=False,
)
)
continue
profile = (
db.query(QuickAccessProfile)
.filter(
QuickAccessProfile.id == record.resource_id,
QuickAccessProfile.scope_type == "user",
QuickAccessProfile.tenant_id == tenant_id,
)
.one_or_none()
)
if (
profile is None
or profile.scope_id != selectors.account_id
or (selectors.profile_id and profile.id != selectors.profile_id)
):
actions.append(_manual_action(record))
continue
actions.append(
DsarErasureActionRef(
action_id=(
"quick_access:delete:quick_access_personal_profile:"
f"{profile.id}:r{profile.revision}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="delete",
resource_type="quick_access_personal_profile",
resource_id=profile.id,
title="Delete personal Quick Access profile",
rationale=(
"The profile contains only account-owned presentation "
"preferences and can be recreated from governed defaults."
),
executable=True,
irreversible=True,
metadata={
"account_id": profile.scope_id,
"revision": profile.revision,
},
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Quick Access DSAR requires one corroborated account.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Tenant Quick Access policy attribution remains "
"institutional accountability evidence."
),
)
)
continue
if action.resource_type != "quick_access_personal_profile":
raise ValueError("Unsupported executable Quick Access DSAR action.")
profile = (
db.query(QuickAccessProfile)
.filter(
QuickAccessProfile.id == action.resource_id,
QuickAccessProfile.scope_type == "user",
QuickAccessProfile.tenant_id == tenant_id,
)
.one_or_none()
)
if profile is None:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary="The personal Quick Access profile was already absent.",
evidence={"request_id": request_id},
)
)
continue
expected_account = str(action.metadata.get("account_id") or "")
expected_revision = action.metadata.get("revision")
if (
profile.scope_id != selectors.account_id
or expected_account != selectors.account_id
or (selectors.profile_id and profile.id != selectors.profile_id)
):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The profile is not owned by the corroborated subject "
"account and selector context."
),
)
)
continue
if expected_revision != profile.revision:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The profile changed after the erasure plan; create a "
"new plan before deleting it."
),
)
)
continue
profile_id = profile.id
db.delete(profile)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal Quick Access profile without "
"changing tenant policy, tools, or domain data."
),
evidence={"request_id": request_id, "profile_id": profile_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
account_id = _coalesce(
subject.account_id,
references.get("quick_access.account"),
references.get("access.account"),
)
profile_id = _coalesce(
references.get("quick_access.profile"),
references.get("quick_access.profile_id"),
)
if _CONFLICT in (account_id, profile_id):
return None
normalized_account = _optional_string(account_id)
if normalized_account is None:
return None
return _SubjectSelectors(
account_id=normalized_account,
profile_id=_optional_string(profile_id),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _personal_record(profile: QuickAccessProfile) -> DsarRecordRef:
return DsarRecordRef(
provider_id="quick_access",
module_id="quick_access",
resource_type="quick_access_personal_profile",
resource_id=profile.id,
category="personal_interface_preference",
title="Personal Quick Access profile",
data={
"revision": profile.revision,
"category_preferences": _preference_projection(
profile.category_preferences
),
"tool_preferences": _preference_projection(profile.tool_preferences),
},
observed_at=_aware(profile.updated_at),
)
def _attribution_record(
profile: QuickAccessProfile,
account_id: str,
) -> DsarRecordRef:
activities = []
if profile.created_by == account_id:
activities.append("created_tenant_quick_access_policy")
if profile.updated_by == account_id:
activities.append("updated_tenant_quick_access_policy")
return DsarRecordRef(
provider_id="quick_access",
module_id="quick_access",
resource_type="quick_access_tenant_attribution",
resource_id=profile.id,
category="operator_accountability_evidence",
title="Tenant Quick Access policy attribution",
data={
"activities": activities,
"revision": profile.revision,
"created_at": _iso(profile.created_at),
"updated_at": _iso(profile.updated_at),
},
observed_at=_aware(profile.updated_at),
immutable_evidence=True,
retention_reason=(
"Tenant policy authorship is retained as institutional "
"accountability evidence; policy preferences are excluded."
),
)
def _preference_projection(value: object) -> dict[str, dict[str, object]]:
if not isinstance(value, Mapping):
raise ValueError("Quick Access preference payload must be an object.")
if len(value) > _MAX_PREFERENCES:
raise ValueError(
"Quick Access preference limit exceeded; review the stored profile."
)
projected: dict[str, dict[str, object]] = {}
for raw_key, raw_entry in value.items():
key = str(raw_key)
if not key or len(key) > 255 or not isinstance(raw_entry, Mapping):
raise ValueError("Quick Access preference entry is invalid.")
entry: dict[str, object] = {}
if isinstance(raw_entry.get("enabled"), bool):
entry["enabled"] = raw_entry["enabled"]
if isinstance(raw_entry.get("forced"), bool):
entry["forced"] = raw_entry["forced"]
order = raw_entry.get("order")
if isinstance(order, int) and not isinstance(order, bool):
entry["order"] = order
projected[key] = entry
return projected
def _manual_action(record: DsarRecordRef) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=f"quick_access:manual_review:{record.resource_type}:{record.resource_id}",
provider_id="quick_access",
module_id="quick_access",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=(
"The selected profile is absent or no longer belongs to the exact "
"tenant, account, and narrowing selectors."
),
executable=False,
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Quick Access DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "quick_access" or record.module_id != "quick_access":
raise ValueError("Quick Access DSAR cannot plan a foreign provider record.")
if (
record.resource_type
not in {
"quick_access_personal_profile",
"quick_access_tenant_attribution",
}
or not record.resource_id
):
raise ValueError("Quick Access DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "quick_access" or action.module_id != "quick_access":
raise ValueError("Quick Access DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("quick_access:"):
raise ValueError("Quick Access DSAR action identity is invalid.")
__all__ = ["QUICK_ACCESS_DSAR_CAPABILITY", "QuickAccessDsarProvider"]
@@ -0,0 +1,32 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'quick-access.data-subject-requests': {'consequence_classes': {'delete_personal_profile': 'Entfernt '
'persönliche '
'Overrides; '
'Reged '
'Defaults '
'gelten '
'wieder.',
'export_personal_profile': 'Gibt '
'begrenzte '
'Schienenpräferenzen '
'zurück, '
'niemals '
'werkzeugeigene '
'Daten.',
'retain_tenant_attribution': 'Preserves '
'minimiert '
'Mandant-Politik '
'Rechenschaftspflicht '
'Nachweise.'}},
'quick-access.user': {'steps': ['Öffnen Sie eine Kategorie auf der richtigen Schiene und wählen '
'Sie ein verfügbares kompaktes Werkzeug.',
'Füllen Sie die begrenzte Aktion des Eigentümermoduls aus oder '
'stornieren Sie sie ausdrücklich.',
'Folgen Sie dem Owning-Page-Link, wenn die Aufgabe die kompakte '
'Oberfläche übersteigt.']}}
+146 -2
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_quick_access.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.access import (
@@ -11,6 +14,8 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -25,11 +30,15 @@ from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_quick_access.backend.db import models as quick_access_models
from govoplan_quick_access.backend.dsar_provider import (
QUICK_ACCESS_DSAR_CAPABILITY,
QuickAccessDsarProvider,
)
MODULE_ID = "quick_access"
MODULE_NAME = "Quick Access"
MODULE_VERSION = "0.1.18"
MODULE_VERSION = "0.1.21"
READ_SCOPE = "quick_access:profile:read"
WRITE_SCOPE = "quick_access:profile:write"
@@ -111,20 +120,98 @@ def _router(context: ModuleContext):
return create_router(context.registry)
def _dsar_provider(_context: ModuleContext) -> QuickAccessDsarProvider:
return QuickAccessDsarProvider()
DOCUMENTATION = (
DocumentationTopic(
id="quick-access.data-subject-requests",
title="Quick Access data-subject requests",
summary=(
"Export or delete personal rail preferences while retaining "
"institutional Quick Access policy."
),
body=(
"Quick Access contributes the user profile owned by the exact account "
"in the active tenant. The access package contains bounded category and "
"tool availability and ordering preferences but never follows a tool "
"into Mail, Postbox, Tasks, Files, or another owner module. Tenant policy "
"records are included only as minimized creation or update attribution "
"when the subject account performed that action; their preference payload "
"is excluded and their attribution is retained as institutional evidence. "
"System-wide policy is outside tenant-scoped requests. Erasure deletes "
"only the selected personal profile after owner and revision checks. The "
"effective rail then falls back to current module, system, and tenant "
"defaults, including all locked or forced items. A repeated execution is "
"unchanged and no tool or domain data is modified."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
translations={
"de": {
"title": "Datenschutzanfragen für Schnellzugriff",
"summary": "Persönliche Leisteneinstellungen exportieren oder löschen, während institutionelle Schnellzugriffsrichtlinien erhalten bleiben.",
"body": (
"Schnellzugriff trägt das Benutzerprofil bei, das dem genauen Konto im aktiven Mandanten gehört. Das Auskunftspaket enthält begrenzte Einstellungen zu Verfügbarkeit und Reihenfolge von Kategorien und Werkzeugen, folgt einem Werkzeug jedoch niemals in Mail, Postbox, Tasks, Files oder ein anderes Besitzermodul. "
"Mandantenrichtlinien werden nur mit minimierter Erstellungs- oder Änderungszuordnung aufgenommen, wenn das betroffene Konto die Handlung ausgeführt hat; ihr Einstellungsinhalt bleibt ausgeschlossen und die Zuordnung bleibt als institutioneller Nachweis erhalten. Systemweite Richtlinien liegen außerhalb mandantenbezogener Anfragen. "
"Eine Löschung entfernt nach Eigentums- und Revisionsprüfung ausschließlich das ausgewählte persönliche Profil. Die wirksame Leiste fällt anschließend auf die aktuellen Modul-, System- und Mandantenvorgaben einschließlich aller gesperrten oder erzwungenen Einträge zurück. Wiederholte Ausführung verändert nichts und keine Werkzeug- oder Fachdaten werden geändert."
),
}
},
related_modules=("core", "access", "views"),
metadata={
"kind": "reference",
"help_contexts": [
"quick_access.rail",
"quick_access.settings.personal",
"quick_access.admin.tenant",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_personal_profile": (
"Returns bounded rail preferences, never tool-owned data."
),
"delete_personal_profile": (
"Removes personal overrides; governed defaults apply again."
),
"retain_tenant_attribution": (
"Preserves minimized tenant-policy accountability evidence."
),
},
},
),
DocumentationTopic(
id="quick-access.user",
title="Quick Access rail",
summary="Keep selected work, calendar, message, and file tools available beside the current page.",
body=(
"Documentation books sit immediately beside the visible heading or contextual label for the "
"displayed Quick Access tool, not among operational action buttons. Field help remains beside "
"its label. "
"Open a category on the right rail to use compact tools without leaving the current task. "
"Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. "
"Personal settings can reorder or hide items that remain available under system, tenant, "
"permission, and View policy. Every item retains a link to its complete owning page. "
"When a View focuses the rail, the rail names that View and offers a temporary All available tools escape that "
"shows the complete permission-derived catalogue without changing the View or personal settings. If none of a "
"View's focused tools is currently available, Quick Access explains the stale focus and falls back to that catalogue. "
"Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, "
"temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and "
"before each effect. A tool returns either an explicit version-1 completion with a typed owner reference or an "
"explicit cancellation; closing the drawer does not imply success. The overlay preserves unsaved host-page work, "
"and the complete owning page remains the fallback for work that exceeds the compact surface."
),
layer="configured",
documentation_types=("user",),
audience=("user",),
conditions=(
DocumentationCondition(
required_modules=("quick_access", "access"),
required_scopes=(READ_SCOPE,),
),
),
links=(
DocumentationLink(
label="Quick Access architecture",
@@ -137,18 +224,35 @@ DOCUMENTATION = (
"title": "Schnellzugriffsleiste",
"summary": "Ausgewaehlte Werkzeuge fuer Arbeit, Kalender, Nachrichten und Dateien neben der aktuellen Seite verwenden.",
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für das angezeigte Schnellzugriffswerkzeug, nicht zwischen ausführbaren "
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
"Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. "
"Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. "
"Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. "
"Wenn eine Ansicht die Leiste fokussiert, benennt die Leiste diese Ansicht und bietet voruebergehend alle "
"verfuegbaren Werkzeuge an, ohne Ansicht oder persoenliche Einstellungen zu aendern. Ist keines der fokussierten "
"Werkzeuge verfuegbar, wird der veraltete Fokus erklaert und der berechtigungsabgeleitete Katalog angezeigt. "
"Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, "
"Zeitbezug, genaue Ansichtsversion und Ruecksprungort. Das besitzende Modul prueft den Zugriff beim Oeffnen "
"und vor jeder Wirkung erneut. Ein Werkzeug meldet entweder einen ausdruecklichen Abschluss mit typisiertem "
"Besitzerverweis oder einen ausdruecklichen Abbruch; das Schliessen gilt nicht als Erfolg. Die Einblendung "
"erhaelt ungespeicherte Arbeit auf der Ausgangsseite, die vollstaendige Besitzerseite bleibt das Ausweichziel."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": [
"quick_access.rail",
"quick_access.drawer",
"quick_access.settings.personal",
]
],
"steps": [
"Open a category on the right rail and choose an available compact tool.",
"Complete or explicitly cancel the bounded owner-module action.",
"Follow the owning-page link when the task exceeds the compact surface.",
],
},
),
DocumentationTopic(
@@ -156,9 +260,19 @@ DOCUMENTATION = (
title="Quick Access policy",
summary="Govern which registered compact tools lower scopes may use and how they are ordered by default.",
body=(
"Documentation books sit immediately beside the visible heading or contextual label for the "
"displayed Quick Access tool, not among operational action buttons. Field help remains beside "
"its label. "
"The catalogue follows installed module registrations. System settings constrain tenants; "
"tenant settings constrain users. An item may remain available, be blocked, or be forced. "
"Effective entries identify the system, tenant, user, or module source of availability and ordering; "
"preferences for retired entries retain their scope provenance. "
"Views and permissions form additional ceilings and Quick Access never grants access to domain data. "
"A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized "
"tools participate. The All available tools escape only restores that permission-derived set for the current session; "
"it never broadens authorization or persists an override. Workflow receives that same presentation from the exact resolved View revision. Launch-context "
"version 2, reference contract version 1, and result contract version 1 fail closed on unknown versions; cross-tenant "
"active-object and result references are rejected."
),
layer="configured",
documentation_types=("admin",),
@@ -168,9 +282,19 @@ DOCUMENTATION = (
"title": "Richtlinien fuer den Schnellzugriff",
"summary": "Verfuegbarkeit und Standardreihenfolge registrierter kompakter Werkzeuge steuern.",
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für das angezeigte Schnellzugriffswerkzeug, nicht zwischen ausführbaren "
"Aktionsschaltflächen. Feldhilfe bleibt neben der Feldbezeichnung. "
"Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, "
"Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. "
"Effektive Eintraege nennen System, Mandant, Benutzer oder Modul als Quelle fuer Verfuegbarkeit und Reihenfolge; "
"Einstellungen fuer entfernte Eintraege behalten ihren Ebenennachweis. "
"Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. "
"Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb "
"der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Alle verfuegbaren Werkzeuge stellt nur diese "
"berechtigungsabgeleitete Menge fuer die laufende Sitzung wieder her und speichert keine Umgehung. Workflow verwendet dieselbe Darstellung aus "
"der genau aufgeloesten Ansichtsversion. Startkontext Version 2 sowie Verweis- und Ergebniskontrakt Version 1 "
"lehnen unbekannte Versionen ab; mandantenfremde Objekt- und Ergebnisverweise werden verworfen."
),
}
},
@@ -199,10 +323,25 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="quick_access.runtime", version="1.0.0"),
ModuleInterfaceProvider(name="quick_access.preferences", version="1.0.0"),
ModuleInterfaceProvider(
name=QUICK_ACCESS_DSAR_CAPABILITY,
version="0.1.0",
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
capability_factories={QUICK_ACCESS_DSAR_CAPABILITY: _dsar_provider},
capability_documentation={
QUICK_ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Quick Access data-subject request provider",
summary=(
"Exports and deletes personal Quick Access preferences while "
"retaining minimized tenant-policy attribution."
),
contract_version="0.1.0",
),
},
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/quick-access-webui",
@@ -301,6 +440,11 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -42,6 +42,7 @@ class CatalogueCategoryResponse(BaseModel):
class CatalogueToolResponse(BaseModel):
contract_version: str
id: str
module_id: str
category_id: str
@@ -55,6 +56,10 @@ class CatalogueToolResponse(BaseModel):
order: int
default_enabled: bool
modes: list[str]
availability: str
accepted_reference_kinds: list[str]
returned_reference_kinds: list[str]
help_context_id: str | None = None
class CatalogueResponse(BaseModel):
@@ -66,17 +71,30 @@ class EffectiveToolResponse(CatalogueToolResponse):
enabled: bool
forced: bool
locked_by: str | None = None
availability_state: Literal["available", "forced", "blocked"]
availability_source: Literal["module", "system", "tenant", "user"]
order_source: Literal["module", "system", "tenant", "user"]
class EffectiveCategoryResponse(CatalogueCategoryResponse):
enabled: bool
forced: bool
locked_by: str | None = None
availability_state: Literal["available", "forced", "blocked"]
availability_source: Literal["module", "system", "tenant", "user"]
order_source: Literal["module", "system", "tenant", "user"]
tools: list[EffectiveToolResponse]
class EffectiveStalePreferenceResponse(BaseModel):
id: str
kind: Literal["category", "tool"]
source: Literal["system", "tenant", "user"]
class EffectiveQuickAccessResponse(BaseModel):
categories: list[EffectiveCategoryResponse]
stale_preferences: list[EffectiveStalePreferenceResponse] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
@@ -18,6 +18,7 @@ from govoplan_quick_access.backend.schemas import (
CatalogueToolResponse,
EffectiveCategoryResponse,
EffectiveQuickAccessResponse,
EffectiveStalePreferenceResponse,
EffectiveToolResponse,
PreferenceEntry,
ProfileResponse,
@@ -91,6 +92,7 @@ def build_catalogue(
continue
tools.append(
CatalogueToolResponse(
contract_version=tool.contract_version,
id=tool.id,
module_id=tool.module_id,
category_id=tool.category_id,
@@ -104,6 +106,10 @@ def build_catalogue(
order=tool.order,
default_enabled=tool.default_enabled,
modes=list(tool.modes),
availability=tool.availability,
accepted_reference_kinds=list(tool.accepted_reference_kinds),
returned_reference_kinds=list(tool.returned_reference_kinds),
help_context_id=tool.help_context_id,
)
)
tools.sort(key=lambda item: (item.category_id, item.order, item.id))
@@ -236,6 +242,7 @@ def resolve_effective(
("user", get_profile(session, scope_type="user", tenant_id=tenant_id, scope_id=account_id)),
)
diagnostics: list[str] = []
stale_preferences = _stale_preferences(profiles, catalogue)
category_states: dict[str, _EffectiveState] = {}
for category in catalogue.categories:
state = _EffectiveState(enabled=True, order=category.order)
@@ -254,6 +261,11 @@ def resolve_effective(
state.apply(entry, source=source)
category_state = category_states.get(tool.category_id)
enabled = state.enabled and bool(category_state and category_state.enabled)
availability_source = (
category_state.availability_source
if state.enabled and category_state and not category_state.enabled
else state.availability_source
)
tool_payload = tool.model_dump()
tool_payload["order"] = state.order
tools_by_category.setdefault(tool.category_id, []).append(
@@ -262,6 +274,9 @@ def resolve_effective(
enabled=enabled,
forced=state.forced,
locked_by=state.locked_by,
availability_state=_availability_state(enabled, state.forced),
availability_source=availability_source,
order_source=state.order_source,
)
)
@@ -281,12 +296,16 @@ def resolve_effective(
enabled=enabled,
forced=state.forced,
locked_by=state.locked_by,
availability_state=_availability_state(enabled, state.forced),
availability_source=state.availability_source,
order_source=state.order_source,
tools=tools,
)
)
categories.sort(key=lambda item: (item.order, item.id))
return EffectiveQuickAccessResponse(
categories=categories,
stale_preferences=stale_preferences,
diagnostics=list(dict.fromkeys(diagnostics)),
)
@@ -297,16 +316,20 @@ class _EffectiveState:
order: int
forced: bool = False
locked_by: str | None = None
availability_source: str = "module"
order_source: str = "module"
def apply(self, entry: PreferenceEntry | None, *, source: str) -> None:
if entry is None:
return
if entry.order is not None:
self.order = entry.order
self.order_source = source
if self.locked_by is not None:
return
if entry.enabled is not None:
self.enabled = entry.enabled
self.availability_source = source
if source != "user" and entry.enabled is False:
self.forced = False
self.locked_by = source
@@ -314,6 +337,38 @@ class _EffectiveState:
self.enabled = True
self.forced = True
self.locked_by = source
self.availability_source = source
def _availability_state(enabled: bool, forced: bool) -> str:
if not enabled:
return "blocked"
return "forced" if forced else "available"
def _stale_preferences(
profiles: tuple[tuple[str, QuickAccessProfile | None], ...],
catalogue: CatalogueResponse,
) -> list[EffectiveStalePreferenceResponse]:
category_ids = {item.id for item in catalogue.categories}
tool_ids = {item.id for item in catalogue.tools}
stale: list[EffectiveStalePreferenceResponse] = []
for source, profile in profiles:
if profile is None:
continue
for kind, field, known_ids in (
("category", "category_preferences", category_ids),
("tool", "tool_preferences", tool_ids),
):
values = getattr(profile, field, {})
if not isinstance(values, Mapping):
continue
stale.extend(
EffectiveStalePreferenceResponse(id=str(item_id), kind=kind, source=source)
for item_id in values
if str(item_id) not in known_ids
)
return sorted(stale, key=lambda item: (item.source, item.kind, item.id))
def _profile_entry(
+377
View File
@@ -0,0 +1,377 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_quick_access.backend.db.models import QuickAccessProfile
from govoplan_quick_access.backend.dsar_provider import (
QUICK_ACCESS_DSAR_CAPABILITY,
QuickAccessDsarProvider,
)
from govoplan_quick_access.backend.manifest import manifest
class _Registry:
def __init__(
self,
provider: QuickAccessDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (QUICK_ACCESS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "quick_access"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("quick_access",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "quick_access"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != QUICK_ACCESS_DSAR_CAPABILITY:
raise KeyError(name)
class QuickAccessDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = QuickAccessDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add_all(
(
QuickAccessProfile(
id="profile-personal",
scope_type="user",
tenant_id="tenant-1",
scope_id="account-1",
scope_key="user:tenant-1:account-1",
category_preferences={"work": {"enabled": False}},
tool_preferences={"tasks.mine": {"order": 5}},
revision=3,
created_by="account-1",
updated_by="account-1",
),
QuickAccessProfile(
id="profile-tenant",
scope_type="tenant",
tenant_id="tenant-1",
scope_id="tenant-1",
scope_key="tenant:tenant-1",
category_preferences={
"private-policy-do-not-export": {"enabled": False}
},
tool_preferences={"tasks.mine": {"forced": True}},
revision=4,
created_by="account-1",
updated_by="account-other",
),
QuickAccessProfile(
id="profile-other-account",
scope_type="user",
tenant_id="tenant-1",
scope_id="account-other",
scope_key="user:tenant-1:account-other",
category_preferences={"calendar": {"enabled": False}},
tool_preferences={},
revision=2,
created_by="account-other",
updated_by="account-other",
),
QuickAccessProfile(
id="profile-other-tenant",
scope_type="user",
tenant_id="tenant-2",
scope_id="account-1",
scope_key="user:tenant-2:account-1",
category_preferences={"files": {"enabled": False}},
tool_preferences={},
revision=2,
created_by="account-1",
updated_by="account-1",
),
QuickAccessProfile(
id="profile-system",
scope_type="system",
tenant_id=None,
scope_id=None,
scope_key="system:*",
category_preferences={"messages": {"enabled": False}},
tool_preferences={},
revision=2,
created_by="account-1",
updated_by="account-1",
),
)
)
def test_search_exports_personal_preferences_and_minimized_attribution(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(
[
"quick_access_personal_profile",
"quick_access_tenant_attribution",
],
[record.resource_type for record in records],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("tasks.mine", exported)
self.assertIn("created_tenant_quick_access_policy", exported)
self.assertNotIn("private-policy-do-not-export", exported)
self.assertNotIn("profile-other-account", exported)
self.assertNotIn("profile-other-tenant", exported)
self.assertNotIn("profile-system", exported)
def test_profile_reference_narrows_and_alias_conflicts_fail_closed(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"quick_access.profile": "profile-personal"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"quick_access.account": "account-other"},
),
)
no_account = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"quick_access.profile": "profile-personal"}
),
)
self.assertEqual(["profile-personal"], [item.resource_id for item in narrowed])
self.assertEqual((), conflict)
self.assertEqual((), no_account)
def test_erasure_deletes_only_personal_profile_and_is_idempotent(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(["delete", "retain"], [action.kind for action in actions])
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual(["executed", "blocked"], [item.status for item in first])
self.assertEqual(["unchanged", "blocked"], [item.status for item in second])
self.assertIsNone(self.session.get(QuickAccessProfile, "profile-personal"))
self.assertIsNotNone(self.session.get(QuickAccessProfile, "profile-tenant"))
self.assertIsNotNone(self.session.get(QuickAccessProfile, "profile-system"))
def test_changed_and_foreign_resources_are_blocked(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
record = next(
item
for item in self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
if item.resource_type == "quick_access_personal_profile"
)
action = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(record,),
)[0]
self.session.get(QuickAccessProfile, "profile-personal").revision += 1
changed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(action,),
request_id="dsar-1",
)
self.assertEqual("blocked", changed[0].status)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="dashboard",
module_id="dashboard",
resource_type="quick_access_personal_profile",
resource_id="profile-personal",
category="preference",
title="Foreign profile",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="dashboard:delete:profile:profile-personal",
provider_id="dashboard",
module_id="dashboard",
kind="delete",
resource_type="quick_access_personal_profile",
resource_id="profile-personal",
title="Delete profile",
rationale="Foreign action",
executable=True,
),
),
request_id="dsar-1",
)
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-QUICK-ACCESS-1",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[QUICK_ACCESS_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
)
self.assertEqual(2, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-QUICK-ACCESS-2",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[QUICK_ACCESS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(QUICK_ACCESS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
QUICK_ACCESS_DSAR_CAPABILITY,
manifest.capability_documentation,
)
self.assertIn(
QUICK_ACCESS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "quick-access.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -12,6 +12,7 @@ from govoplan_core.core.modules import (
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_quick_access.backend.db.models import QuickAccessProfile
from govoplan_quick_access.backend.manifest import manifest
from govoplan_quick_access.backend.service import build_catalogue, resolve_effective
@@ -49,6 +50,31 @@ class QuickAccessTests(unittest.TestCase):
def tearDown(self) -> None:
self.engine.dispose()
def test_documentation_has_localized_workflow_and_reference_baselines(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
self.assertTrue(
all(
all(
topic.translations.get("de", {}).get(field)
for field in ("title", "summary", "body")
)
for topic in topics.values()
)
)
workflow = topics["quick-access.user"]
self.assertEqual("workflow", workflow.metadata["kind"])
self.assertTrue(workflow.conditions)
self.assertTrue(
all(
condition.required_scopes or condition.any_scopes
for condition in workflow.conditions
)
)
self.assertEqual(
"reference",
topics["quick-access.data-subject-requests"].metadata["kind"],
)
def test_catalogue_is_derived_from_manifest_tools(self) -> None:
catalogue = build_catalogue(registry_with_tools())
@@ -136,6 +162,91 @@ class QuickAccessTests(unittest.TestCase):
self.assertTrue(work.enabled)
self.assertTrue(work.forced)
self.assertEqual("tenant", work.locked_by)
self.assertEqual("forced", work.availability_state)
self.assertEqual("tenant", work.availability_source)
def test_effective_resolution_reports_availability_and_order_provenance(self) -> None:
with Session(self.engine) as session:
session.add_all(
(
QuickAccessProfile(
scope_type="system",
tenant_id=None,
scope_id=None,
scope_key="system:*",
category_preferences={},
tool_preferences={"example.work": {"enabled": True, "order": 40}},
revision=2,
),
QuickAccessProfile(
scope_type="user",
tenant_id="tenant-1",
scope_id="account-1",
scope_key="user:tenant-1:account-1",
category_preferences={},
tool_preferences={"example.work": {"order": 5}},
revision=2,
),
)
)
session.commit()
effective = resolve_effective(
session,
registry=registry_with_tools(),
tenant_id="tenant-1",
account_id="account-1",
permission_checker=lambda _scope: True,
)
tool = next(item for item in effective.categories if item.id == "work").tools[0]
self.assertEqual("available", tool.availability_state)
self.assertEqual("system", tool.availability_source)
self.assertEqual("user", tool.order_source)
self.assertEqual(5, tool.order)
def test_effective_resolution_retains_stale_preferences_with_scope_provenance(self) -> None:
with Session(self.engine) as session:
session.add_all(
(
QuickAccessProfile(
scope_type="tenant",
tenant_id="tenant-1",
scope_id="tenant-1",
scope_key="tenant:tenant-1",
category_preferences={"retired.category": {"enabled": False}},
tool_preferences={"retired.tool": {"order": 5}},
revision=2,
),
QuickAccessProfile(
scope_type="user",
tenant_id="tenant-1",
scope_id="account-1",
scope_key="user:tenant-1:account-1",
category_preferences={},
tool_preferences={"missing.user-tool": {"enabled": True}},
revision=2,
),
)
)
session.commit()
effective = resolve_effective(
session,
registry=registry_with_tools(),
tenant_id="tenant-1",
account_id="account-1",
permission_checker=lambda _scope: True,
)
self.assertEqual(
[
("retired.category", "category", "tenant"),
("retired.tool", "tool", "tenant"),
("missing.user-tool", "tool", "user"),
],
[(item.id, item.kind, item.source) for item in effective.stale_preferences],
)
if __name__ == "__main__":
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/quick-access-webui",
"version": "0.1.18",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
+16
View File
@@ -27,6 +27,7 @@ export type QuickAccessCategory = {
};
export type QuickAccessTool = {
contract_version: "1";
id: string;
module_id: string;
category_id: string;
@@ -40,6 +41,10 @@ export type QuickAccessTool = {
order: number;
default_enabled: boolean;
modes: string[];
availability: "global" | "active_object";
accepted_reference_kinds: string[];
returned_reference_kinds: string[];
help_context_id?: string | null;
};
export type QuickAccessCatalogue = {
@@ -51,17 +56,28 @@ export type EffectiveQuickAccessTool = QuickAccessTool & {
enabled: boolean;
forced: boolean;
locked_by?: string | null;
availability_state: "available" | "forced" | "blocked";
availability_source: "module" | "system" | "tenant" | "user";
order_source: "module" | "system" | "tenant" | "user";
};
export type EffectiveQuickAccessCategory = QuickAccessCategory & {
enabled: boolean;
forced: boolean;
locked_by?: string | null;
availability_state: "available" | "forced" | "blocked";
availability_source: "module" | "system" | "tenant" | "user";
order_source: "module" | "system" | "tenant" | "user";
tools: EffectiveQuickAccessTool[];
};
export type EffectiveQuickAccess = {
categories: EffectiveQuickAccessCategory[];
stale_preferences: Array<{
id: string;
kind: "category" | "tool";
source: "system" | "tenant" | "user";
}>;
diagnostics: string[];
};
+176 -10
View File
@@ -1,7 +1,9 @@
import {
CalendarDays,
Files,
Grid2X2,
ListChecks,
ListFilter,
MessagesSquare,
Settings2,
X,
@@ -11,8 +13,14 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { Link, useLocation } from "react-router";
import {
DismissibleAlert,
DocumentationHelpLink,
TextWithHelp,
IconButton,
LoadingFrame,
dispatchQuickAccessResult,
i18nMessage,
quickAccessLaunchState,
useGuardedNavigate,
usePlatformLanguage,
usePlatformUiCapabilities,
type QuickAccessRailProps,
@@ -32,14 +40,16 @@ const iconByCategory: Record<string, LucideIcon> = {
files: Files
};
export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRailProps) {
export default function QuickAccessRail({ settings, auth, tools, launchContext }: QuickAccessRailProps) {
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const [showAllAvailable, setShowAllAvailable] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const drawerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const location = useLocation();
const navigate = useGuardedNavigate();
const contributions = usePlatformUiCapabilities<QuickAccessToolsUiCapability>("quickAccess.tools");
const { translateText } = usePlatformLanguage();
const renderers = useMemo(
@@ -47,12 +57,62 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
[contributions]
);
const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]);
const categories = useMemo(
() => (effective?.categories ?? []).map((category) => ({
const metadataById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
const focusedToolIds = useMemo(
() => new Set(launchContext.viewContext?.focusedToolIds ?? []),
[launchContext.viewContext?.focusedToolIds]
);
const recommendedToolIds = useMemo(
() => new Set(launchContext.viewContext?.recommendedToolIds ?? []),
[launchContext.viewContext?.recommendedToolIds]
);
const eligibleCategories = useMemo(() =>
(effective?.categories ?? [])
.filter((category) => category.enabled)
.map((category) => ({
...category,
tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id))
})).filter((category) => category.enabled && category.tools.length > 0),
[availableToolIds, effective]
tools: category.tools.filter((tool) => {
if (!tool.enabled || !availableToolIds.has(tool.id)) return false;
const metadata = metadataById.get(tool.id);
if (metadata?.availability === "active_object" && !launchContext.activeObject) return false;
if (metadata?.acceptedReferenceKinds.length && launchContext.activeObject) {
const activeKind = `${launchContext.activeObject.ownerModule}.${launchContext.activeObject.kind}`;
if (!metadata.acceptedReferenceKinds.includes(activeKind)) return false;
}
return true;
})
}))
.filter((category) => category.tools.length > 0),
[availableToolIds, effective, launchContext.activeObject, metadataById]
);
const hasEligibleFocus = useMemo(
() => eligibleCategories.some((category) =>
category.tools.some((tool) => focusedToolIds.has(tool.id))
),
[eligibleCategories, focusedToolIds]
);
const hiddenByFocusCount = useMemo(
() => hasEligibleFocus
? eligibleCategories.reduce(
(count, category) => count + category.tools.filter((tool) => !focusedToolIds.has(tool.id)).length,
0
)
: 0,
[eligibleCategories, focusedToolIds, hasEligibleFocus]
);
const categories = useMemo(() => {
return eligibleCategories.map((category) => ({
...category,
tools: category.tools.filter((tool) =>
!hasEligibleFocus || showAllAvailable || focusedToolIds.has(tool.id)
).sort((left, right) =>
Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id))
|| left.order - right.order
|| left.id.localeCompare(right.id)
)
})).filter((category) => category.enabled && category.tools.length > 0);
},
[eligibleCategories, focusedToolIds, hasEligibleFocus, recommendedToolIds, showAllAvailable]
);
const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null;
@@ -104,6 +164,10 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
setActiveCategoryId(null);
}, [location.pathname, location.search]);
useEffect(() => {
setShowAllAvailable(false);
}, [launchContext.viewContext?.viewId, launchContext.viewContext?.revisionId]);
if (!loading && categories.length === 0 && !error) return null;
function closeDrawer(restoreFocus = true) {
@@ -125,9 +189,42 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
setActiveCategoryId(category.id);
}
function openFullPage(path: string) {
closeDrawer(false);
navigate(path, { state: quickAccessLaunchState(launchContext) });
}
function toggleViewFocus() {
setShowAllAvailable((current) => !current);
}
const viewName = launchContext.viewContext?.name?.trim()
|| translateText("i18n:govoplan-quick-access.current_view");
const viewFocusRequested = focusedToolIds.size > 0;
const canToggleViewFocus = hasEligibleFocus && hiddenByFocusCount > 0;
const viewModeLabel = showAllAvailable
? translateText("i18n:govoplan-quick-access.restore_view_focus")
: translateText("i18n:govoplan-quick-access.show_all_available");
const viewModeExplanation = !hasEligibleFocus
? translateText(i18nMessage("i18n:govoplan-quick-access.focus_unavailable", { value0: viewName }))
: showAllAvailable
? translateText(i18nMessage("i18n:govoplan-quick-access.showing_all_for_view", { value0: viewName }))
: translateText(i18nMessage("i18n:govoplan-quick-access.focused_by_view", { value0: viewName }));
const viewFocusMode = !viewFocusRequested
? "none"
: !hasEligibleFocus
? "unavailable"
: showAllAvailable
? "all"
: "focused";
return (
<>
<aside className="quick-access-rail" aria-label="i18n:govoplan-quick-access.quick_access">
<aside
className="quick-access-rail"
aria-label="i18n:govoplan-quick-access.quick_access"
data-view-focus-mode={viewFocusMode}
>
<div className="quick-access-rail-tools">
{loading ? <span className="quick-access-rail-loading" aria-label="i18n:govoplan-quick-access.loading" /> : null}
{categories.map((category) => {
@@ -149,6 +246,20 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
);
})}
</div>
{canToggleViewFocus ? (
<button
type="button"
className={`quick-access-rail-button quick-access-view-mode-button${showAllAvailable ? " active" : ""}`}
title={viewModeLabel}
aria-label={viewModeLabel}
aria-pressed={showAllAvailable}
onClick={toggleViewFocus}
>
{showAllAvailable
? <ListFilter size={19} aria-hidden="true" />
: <Grid2X2 size={19} aria-hidden="true" />}
</button>
) : null}
<Link
className="quick-access-rail-button quick-access-settings-link"
to="/settings?section=quick-access"
@@ -176,21 +287,76 @@ export default function QuickAccessRail({ settings, auth, tools }: QuickAccessRa
<IconButton label="i18n:govoplan-quick-access.close" icon={<X size={18} />} variant="ghost" onClick={() => closeDrawer()} />
</header>
<div className="quick-access-drawer-content">
{viewFocusRequested ? (
<div className="quick-access-view-mode" role="status" data-view-focus-mode={viewFocusMode}>
<span>{viewModeExplanation}</span>
{canToggleViewFocus ? (
<button type="button" onClick={toggleViewFocus}>{viewModeLabel}</button>
) : null}
</div>
) : null}
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
{activeCategory.tools.map((tool) => {
const renderer = renderers.get(tool.id);
const metadata = metadataById.get(tool.id);
return (
<section className="quick-access-tool" key={tool.id} data-tool-id={tool.id}>
<section
className={`quick-access-tool${recommendedToolIds.has(tool.id) ? " is-recommended" : ""}`}
key={tool.id}
data-tool-id={tool.id}
data-tool-contract-version={metadata?.contractVersion}
>
<div className="quick-access-tool-heading">
<div>
<TextWithHelp help={metadata?.helpContextId ? (
<DocumentationHelpLink reference={{ contextId: metadata.helpContextId, moduleId: tool.module_id }} />
) : undefined}>
<strong>{translateText(tool.label)}</strong>
</TextWithHelp>
{recommendedToolIds.has(tool.id) ? (
<span className="quick-access-recommended">
{translateText("i18n:govoplan-quick-access.recommended_for_view")}
</span>
) : null}
{tool.description ? <small>{translateText(tool.description)}</small> : null}
</div>
{tool.full_page_path ? <Link to={tool.full_page_path} onClick={() => closeDrawer(false)}>i18n:govoplan-quick-access.open_full_page</Link> : null}
{tool.full_page_path ? (
<button
type="button"
className="quick-access-full-page-link"
onClick={() => openFullPage(tool.full_page_path!)}
>
i18n:govoplan-quick-access.open_full_page
</button>
) : null}
</div>
{renderer
? renderer.render({ settings, auth, close: () => closeDrawer(), active: true })
? renderer.render({
settings,
auth,
close: () => closeDrawer(),
active: true,
launchContext,
complete: (result) => {
if (!dispatchQuickAccessResult(tool.id, launchContext, result, metadata?.returnedReferenceKinds ?? [])) {
setError("i18n:govoplan-quick-access.invalid_result");
return;
}
closeDrawer();
},
cancel: (reason = "user") => {
if (!dispatchQuickAccessResult(tool.id, launchContext, {
contractVersion: "1",
outcome: "cancelled",
reason
}, metadata?.returnedReferenceKinds ?? [])) {
setError("i18n:govoplan-quick-access.invalid_result");
return;
}
closeDrawer();
}
})
: <p className="quick-access-tool-unavailable">i18n:govoplan-quick-access.compact_view_unavailable</p>}
</section>
);
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import {
Button,
DismissibleAlert,
FormSection,
IconButton,
i18nMessage,
LoadingFrame,
@@ -31,6 +32,11 @@ type Draft = {
tools: Record<string, QuickAccessPreference>;
};
type AvailabilityMode = "inherit" | "available" | "blocked" | "forced";
type EffectiveProvenance = {
state: "available" | "forced" | "blocked";
availabilitySource: "module" | "system" | "tenant" | "user";
orderSource: "module" | "system" | "tenant" | "user";
};
const EMPTY_DRAFT: Draft = { categories: {}, tools: {} };
@@ -185,6 +191,14 @@ export default function QuickAccessSettingsPanel({
const lockedTools = new Map(
(effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
);
const categoryProvenance = new Map(
(effective?.categories ?? []).map((item) => [item.id, effectiveProvenance(item)])
);
const toolProvenance = new Map(
(effective?.categories ?? []).flatMap((category) =>
category.tools.map((item) => [item.id, effectiveProvenance(item)] as const)
)
);
const categoryIds = categories.map((item) => item.id);
return (
@@ -195,8 +209,8 @@ export default function QuickAccessSettingsPanel({
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
</div>
<div className="quick-access-settings-actions">
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
<Button variant="secondary" disabled={!dirty || saving} onClick={reset}><RotateCcw size={16} aria-hidden="true" />i18n:govoplan-quick-access.discard</Button>
<Button variant="primary" disabled={!dirty || saving || !canWrite} onClick={() => void save()}><Save size={16} aria-hidden="true" />i18n:govoplan-quick-access.save</Button>
</div>
</div>
@@ -208,9 +222,7 @@ export default function QuickAccessSettingsPanel({
) : null}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
<section className="quick-access-settings-section">
<h3>i18n:govoplan-quick-access.categories</h3>
<div className="quick-access-preference-list">
<FormSection className="quick-access-settings-section" title="i18n:govoplan-quick-access.categories" contentClassName="quick-access-preference-list">
{categories.map((category, index) => (
<PreferenceRow
key={category.id}
@@ -220,6 +232,7 @@ export default function QuickAccessSettingsPanel({
preference={draft.categories[category.id]}
isPersonal={isPersonal}
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
provenance={categoryProvenance.get(category.id)}
disabled={!canWrite || saving}
onChange={(value) => setPreference("categories", category.id, value)}
onMoveUp={() => move("categories", categoryIds, category.id, -1)}
@@ -228,12 +241,9 @@ export default function QuickAccessSettingsPanel({
last={index === categories.length - 1}
/>
))}
</div>
</section>
</FormSection>
<section className="quick-access-settings-section">
<h3>i18n:govoplan-quick-access.registered_tools</h3>
<div className="quick-access-preference-list">
<FormSection className="quick-access-settings-section" title="i18n:govoplan-quick-access.registered_tools" contentClassName="quick-access-preference-list">
{categories.flatMap((category) => {
const categoryTools = tools.filter((tool) => tool.category_id === category.id);
const toolIds = categoryTools.map((tool) => tool.id);
@@ -247,6 +257,7 @@ export default function QuickAccessSettingsPanel({
defaultEnabled={tool.default_enabled}
isPersonal={isPersonal}
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
provenance={toolProvenance.get(tool.id)}
disabled={!canWrite || saving}
onChange={(value) => setPreference("tools", tool.id, value)}
onMoveUp={() => move("tools", toolIds, tool.id, -1)}
@@ -257,8 +268,7 @@ export default function QuickAccessSettingsPanel({
));
})}
{!tools.length ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
</div>
</section>
</FormSection>
</LoadingFrame>
</div>
);
@@ -273,6 +283,7 @@ function PreferenceRow({
defaultEnabled = true,
isPersonal,
lockedBy,
provenance,
disabled,
onChange,
onMoveUp,
@@ -287,6 +298,7 @@ function PreferenceRow({
defaultEnabled?: boolean;
isPersonal: boolean;
lockedBy?: string | null;
provenance?: EffectiveProvenance;
disabled: boolean;
onChange: (value: QuickAccessPreference | null) => void;
onMoveUp: () => void;
@@ -308,6 +320,15 @@ function PreferenceRow({
})}
</small>
) : null}
{provenance ? (
<small>
{i18nMessage("i18n:govoplan-quick-access.effective_provenance", {
value0: provenance.state,
value1: provenance.availabilitySource,
value2: provenance.orderSource
})}
</small>
) : null}
</div>
<div className="quick-access-preference-controls">
{isPersonal ? (
@@ -343,6 +364,18 @@ function PreferenceRow({
);
}
function effectiveProvenance(item: {
availability_state: "available" | "forced" | "blocked";
availability_source: "module" | "system" | "tenant" | "user";
order_source: "module" | "system" | "tenant" | "user";
}): EffectiveProvenance {
return {
state: item.availability_state,
availabilitySource: item.availability_source,
orderSource: item.order_source
};
}
function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode {
if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit";
+20 -2
View File
@@ -8,6 +8,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen",
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen",
"i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.",
"i18n:govoplan-quick-access.recommended_for_view": "Für diese Ansicht empfohlen",
"i18n:govoplan-quick-access.current_view": "aktuelle Ansicht",
"i18n:govoplan-quick-access.show_all_available": "Alle verfügbaren Werkzeuge anzeigen",
"i18n:govoplan-quick-access.restore_view_focus": "Ansichtsfokus wiederherstellen",
"i18n:govoplan-quick-access.focused_by_view": "Durch die Ansicht {value0} fokussiert. Weitere berechtigte Werkzeuge bleiben verfügbar.",
"i18n:govoplan-quick-access.showing_all_for_view": "Alle für Sie verfügbaren Werkzeuge werden angezeigt; die Ansicht {value0} bleibt aktiv.",
"i18n:govoplan-quick-access.focus_unavailable": "Der Werkzeugfokus der Ansicht {value0} ist derzeit nicht verfügbar. Alle berechtigten Werkzeuge werden angezeigt.",
"i18n:govoplan-quick-access.invalid_result": "Das Werkzeug hat ein ungültiges oder nicht zugelassenes Ergebnis zurückgegeben.",
"i18n:govoplan-quick-access.settings_title": "Schnellzugriff",
"i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.",
"i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.",
@@ -36,7 +44,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postfach und künftige Gesprächskanäle in einer Einblendung.",
"i18n:govoplan-quick-access.category.files": "Dateien",
"i18n:govoplan-quick-access.category.files_description": "Aktuelle und kontextbezogene Dateien, ohne die laufende Aufgabe zu verlassen.",
"i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt"
"i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt",
"i18n:govoplan-quick-access.effective_provenance": "Effektiv {value0}; Verfügbarkeit von {value1}, Reihenfolge von {value2}"
},
en: {
"i18n:govoplan-quick-access.quick_access": "Quick Access",
@@ -45,6 +54,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.loading": "Loading Quick Access",
"i18n:govoplan-quick-access.open_full_page": "Open full page",
"i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.",
"i18n:govoplan-quick-access.recommended_for_view": "Recommended for this View",
"i18n:govoplan-quick-access.current_view": "current View",
"i18n:govoplan-quick-access.show_all_available": "Show all available tools",
"i18n:govoplan-quick-access.restore_view_focus": "Restore View focus",
"i18n:govoplan-quick-access.focused_by_view": "Focused by the {value0} View. Other authorized tools remain available.",
"i18n:govoplan-quick-access.showing_all_for_view": "Showing all tools available to you; the {value0} View remains active.",
"i18n:govoplan-quick-access.focus_unavailable": "The {value0} View's tool focus is currently unavailable. All authorized tools are shown.",
"i18n:govoplan-quick-access.invalid_result": "The tool returned an invalid or undeclared result.",
"i18n:govoplan-quick-access.settings_title": "Quick Access",
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.",
"i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.",
@@ -73,6 +90,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.category.messages_description": "Mail, Postbox, and future conversational channels in one overlay.",
"i18n:govoplan-quick-access.category.files": "Files",
"i18n:govoplan-quick-access.category.files_description": "Recent and contextual files without leaving the current task.",
"i18n:govoplan-quick-access.locked_by_value": "Set by {value0}"
"i18n:govoplan-quick-access.locked_by_value": "Set by {value0}",
"i18n:govoplan-quick-access.effective_provenance": "Effective {value0}; availability from {value1}, order from {value2}"
}
};
+1
View File
@@ -1,2 +1,3 @@
export { default, quickAccessModule } from "./module";
export { default as QuickAccessRail } from "./components/QuickAccessRail";
export * from "./api/quickAccess";
+1 -1
View File
@@ -77,7 +77,7 @@ const adminSections: AdminSectionsUiCapability = {
export const quickAccessModule: PlatformWebModule = {
id: "quick_access",
label: "i18n:govoplan-quick-access.quick_access",
version: "0.1.18",
version: "0.1.19",
dependencies: ["access"],
optionalDependencies: ["views", "policy"],
translations: generatedTranslations,
+85 -5
View File
@@ -34,7 +34,7 @@
display: grid;
place-items: center;
border: 0;
border-radius: 4px;
border-radius: var(--radius-sm);
color: var(--muted);
background: transparent;
cursor: pointer;
@@ -55,13 +55,22 @@
margin: auto 4px 6px;
}
.quick-access-view-mode-button {
flex: 0 0 auto;
margin: auto 4px 3px;
}
.quick-access-view-mode-button + .quick-access-settings-link {
margin-top: 0;
}
.quick-access-rail-loading {
width: 18px;
height: 18px;
margin: 11px auto;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
border-radius: var(--radius-round);
animation: quick-access-spin 0.8s linear infinite;
}
@@ -76,7 +85,7 @@
display: flex;
flex-direction: column;
border-left: var(--border-line);
box-shadow: -12px 0 30px rgb(0 0 0 / 18%);
box-shadow: var(--shadow-drawer-side);
background: var(--panel);
}
@@ -117,6 +126,36 @@
overflow-y: auto;
}
.quick-access-view-mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: var(--border-line);
padding: 9px 12px 9px 16px;
background: var(--accent-soft);
color: var(--text);
font-size: 12px;
line-height: 1.4;
}
.quick-access-view-mode button {
flex: 0 0 auto;
border: 0;
padding: 2px 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 700;
}
.quick-access-view-mode button:hover,
.quick-access-view-mode button:focus-visible {
text-decoration: underline;
}
.quick-access-tool {
display: grid;
gap: 12px;
@@ -124,6 +163,21 @@
padding: 14px 16px 16px;
}
.quick-access-tool.is-recommended {
border-inline-start: 3px solid var(--accent);
padding-inline-start: 13px;
}
.quick-access-recommended {
width: fit-content;
border-radius: var(--radius-round);
padding: 1px 7px;
background: var(--accent-soft);
color: var(--accent);
font-size: 11px;
font-weight: 700;
}
.quick-access-tool-heading,
.quick-access-settings-heading,
.quick-access-preference-row,
@@ -141,12 +195,25 @@
justify-content: space-between;
}
.quick-access-tool-heading > a {
.quick-access-tool-heading > a,
.quick-access-full-page-link {
flex: 0 0 auto;
color: var(--accent);
font-size: 12px;
}
.quick-access-full-page-link {
border: 0;
background: transparent;
font-family: inherit;
font-weight: 700;
padding: 0;
cursor: pointer;
}
.quick-access-full-page-link:hover,
.quick-access-full-page-link:focus-visible { text-decoration: underline; }
.quick-access-settings {
min-width: 0;
display: grid;
@@ -242,6 +309,14 @@
margin: 4px 6px 4px auto;
}
.quick-access-view-mode-button {
margin: 4px 3px 4px auto;
}
.quick-access-view-mode-button + .quick-access-settings-link {
margin-left: 0;
}
.quick-access-drawer {
right: 0;
bottom: 50px;
@@ -250,7 +325,12 @@
height: min(70vh, 680px);
border-top: var(--border-line);
border-left: 0;
box-shadow: 0 -12px 30px rgb(0 0 0 / 18%);
box-shadow: var(--shadow-drawer-bottom);
}
.quick-access-view-mode {
align-items: flex-start;
flex-direction: column;
}
.quick-access-preference-row {