` element. Theme applies at once; language and form reload the page because the server renders the text. `LocaleMiddleware` is replaced by `PreferencesMiddleware`. The `de_DU` catalogue fell back to `de` as hoped (tested). No `locale/en` catalogue: the message ids are English. Placeholder pages live at `/publish/announcements` and `/administration/{appearance,settings,audit,status}`.
+- Not verified: the Docker build (Node stage, `compilemessages`) and the visual result in a browser, including the Fluent avatar tokens and the phone layout, since neither runs on the Windows machine.
diff --git a/portal/config/settings.py b/portal/config/settings.py
index f9cac24..502b0d3 100644
--- a/portal/config/settings.py
+++ b/portal/config/settings.py
@@ -33,6 +33,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"portal.audit",
"portal.accounts",
+ "portal.shell",
]
MIDDLEWARE = [
@@ -44,7 +45,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"portal.accounts.middleware.AbsoluteSessionLimitMiddleware",
"portal.accounts.middleware.CurrentUserMiddleware",
- "django.middleware.locale.LocaleMiddleware",
+ "portal.shell.middleware.PreferencesMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -62,6 +63,7 @@ TEMPLATES = [
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.i18n",
+ "portal.shell.context.shell",
],
},
}
diff --git a/portal/config/urls.py b/portal/config/urls.py
index fc9b124..6933f6e 100644
--- a/portal/config/urls.py
+++ b/portal/config/urls.py
@@ -5,4 +5,5 @@ from .health import healthz
urlpatterns = [
path("healthz", healthz, name="healthz"),
path("", include("portal.accounts.urls")),
+ path("", include("portal.shell.urls")),
]
diff --git a/portal/frontend/build.mjs b/portal/frontend/build.mjs
new file mode 100644
index 0000000..6befce3
--- /dev/null
+++ b/portal/frontend/build.mjs
@@ -0,0 +1,12 @@
+// Bundles entry.js into one minified file the page loads from its own origin (AD-16).
+import { build } from "esbuild";
+
+await build({
+ entryPoints: ["entry.js"],
+ bundle: true,
+ minify: true,
+ format: "iife",
+ target: ["es2022"],
+ legalComments: "none",
+ outfile: "dist/fluent.js",
+});
diff --git a/portal/frontend/entry.js b/portal/frontend/entry.js
new file mode 100644
index 0000000..978ca95
--- /dev/null
+++ b/portal/frontend/entry.js
@@ -0,0 +1,2 @@
+// Only the components the shell uses; each import registers its custom element.
+import "@fluentui/web-components/avatar/define.js";
diff --git a/portal/frontend/package-lock.json b/portal/frontend/package-lock.json
new file mode 100644
index 0000000..9b7e4d0
--- /dev/null
+++ b/portal/frontend/package-lock.json
@@ -0,0 +1,557 @@
+{
+ "name": "portal-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "portal-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@fluentui/web-components": "3.1.3"
+ },
+ "devDependencies": {
+ "esbuild": "0.28.2"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fluentui/tokens": {
+ "version": "1.0.0-alpha.24",
+ "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.24.tgz",
+ "integrity": "sha512-YrjGQfnxBR+9o8wXyTflON3Ohsv7OrZWYLVRU+7RaiFO1gcu36iDm3WjudlVmYuWRLDO0Gmxotm6RQNhbZuEQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.1"
+ }
+ },
+ "node_modules/@fluentui/web-components": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@fluentui/web-components/-/web-components-3.1.3.tgz",
+ "integrity": "sha512-1jl4CHoFFSfevkr8l7F+dKNJzyMWBZqwQfB/khNZtwyY0rv87799mpDWSVu0QeiZAFEbqCtnAOnXWsxJ/kek+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@fluentui/tokens": "^1.0.0-alpha.24",
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": "^22.0.0 || ^24.0.0"
+ },
+ "peerDependencies": {
+ "@microsoft/fast-element": "^3.0.0",
+ "@microsoft/focusgroup-polyfill": "^1.5.0"
+ }
+ },
+ "node_modules/@microsoft/fast-element": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@microsoft/fast-element/-/fast-element-3.1.2.tgz",
+ "integrity": "sha512-LmyiGq1zjszwgcqSkiOsZph+9NUZJqnmBhuqPDmqndWfyo0bMXKPTevQX9q3nEKCuIIFDMsGdPNGcQ1xoK7sGw==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@microsoft/focusgroup-polyfill": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@microsoft/focusgroup-polyfill/-/focusgroup-polyfill-1.6.0.tgz",
+ "integrity": "sha512-SlJO8FKrdFdSEXtNTNPTlCeqyqbpyLf6suYHTK7G8WYXZf8/BWK0ucERiPgrUIpNewfQjV82BahSOW/p8YNJ1A==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ }
+ }
+}
diff --git a/portal/frontend/package.json b/portal/frontend/package.json
new file mode 100644
index 0000000..c3d7060
--- /dev/null
+++ b/portal/frontend/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "portal-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Builds the Fluent web components bundle that is copied into the image.",
+ "type": "module",
+ "scripts": {
+ "build": "node build.mjs"
+ },
+ "dependencies": {
+ "@fluentui/web-components": "3.1.3"
+ },
+ "devDependencies": {
+ "esbuild": "0.28.2"
+ }
+}
diff --git a/portal/locale/de/LC_MESSAGES/django.po b/portal/locale/de/LC_MESSAGES/django.po
new file mode 100644
index 0000000..cfa938f
--- /dev/null
+++ b/portal/locale/de/LC_MESSAGES/django.po
@@ -0,0 +1,109 @@
+# German, formal address (Sie)
+msgid ""
+msgstr ""
+"Project-Id-Version: self-service-portal\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "No permission"
+msgstr "Keine Berechtigung"
+
+msgid "You do not have permission to open this page."
+msgstr "Dazu fehlt Ihnen die Berechtigung."
+
+msgid "Back to the portal"
+msgstr "Zurück zum Portal"
+
+msgid "Home"
+msgstr "Startseite"
+
+#, python-format
+msgid "Hello, %(name)s"
+msgstr "Guten Tag, %(name)s"
+
+msgid "You are signed in. More pages will follow here."
+msgstr "Sie sind angemeldet. Weitere Seiten folgen hier."
+
+msgid "Sign-in not possible"
+msgstr "Anmeldung nicht möglich"
+
+msgid "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator."
+msgstr "Mit diesem Konto können Sie sich nicht am Portal anmelden. Wenn Sie glauben, dass das ein Fehler ist, wenden Sie sich an die Administration."
+
+msgid "Try again"
+msgstr "Erneut versuchen"
+
+msgid "Signed out"
+msgstr "Abgemeldet"
+
+msgid "You have been signed out"
+msgstr "Sie wurden abgemeldet."
+
+msgid "Sign in again"
+msgstr "Erneut anmelden"
+
+msgid "Not available"
+msgstr "Nicht verfügbar"
+
+msgid "This is not possible right now. Please try again later."
+msgstr "Das ist gerade nicht möglich. Bitte versuchen Sie es später erneut."
+
+msgid "Skip to content"
+msgstr "Zum Inhalt springen"
+
+msgid "Menu"
+msgstr "Menü"
+
+msgid "Profile menu"
+msgstr "Profilmenü"
+
+msgid "Main menu"
+msgstr "Hauptmenü"
+
+msgid "Theme"
+msgstr "Design"
+
+msgid "Language"
+msgstr "Sprache"
+
+msgid "Form of address"
+msgstr "Anrede"
+
+msgid "Sign out"
+msgstr "Abmelden"
+
+msgid "This page is not available yet."
+msgstr "Diese Seite folgt."
+
+msgid "Light"
+msgstr "Hell"
+
+msgid "Dark"
+msgstr "Dunkel"
+
+msgid "System"
+msgstr "System"
+
+msgid "Publishing"
+msgstr "Veröffentlichen"
+
+msgid "Manage announcements"
+msgstr "Ankündigungen verwalten"
+
+msgid "Administration"
+msgstr "Administration"
+
+msgid "Appearance"
+msgstr "Darstellung"
+
+msgid "Settings"
+msgstr "Einstellungen"
+
+msgid "Audit log"
+msgstr "Protokoll"
+
+msgid "Status"
+msgstr "Status"
diff --git a/portal/locale/de_DU/LC_MESSAGES/django.po b/portal/locale/de_DU/LC_MESSAGES/django.po
new file mode 100644
index 0000000..d985de7
--- /dev/null
+++ b/portal/locale/de_DU/LC_MESSAGES/django.po
@@ -0,0 +1,28 @@
+# German, informal address (du); only the differences, the rest falls back to de
+msgid ""
+msgstr ""
+"Project-Id-Version: self-service-portal\n"
+"Language: de_DU\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "You do not have permission to open this page."
+msgstr "Dazu fehlt dir die Berechtigung."
+
+#, python-format
+msgid "Hello, %(name)s"
+msgstr "Hallo, %(name)s"
+
+msgid "You are signed in. More pages will follow here."
+msgstr "Du bist angemeldet. Weitere Seiten folgen hier."
+
+msgid "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator."
+msgstr "Mit diesem Konto kannst du dich nicht am Portal anmelden. Wenn du glaubst, dass das ein Fehler ist, wende dich an die Administration."
+
+msgid "You have been signed out"
+msgstr "Du wurdest abgemeldet."
+
+msgid "This is not possible right now. Please try again later."
+msgstr "Das ist gerade nicht möglich. Bitte versuche es später erneut."
diff --git a/portal/shell/__init__.py b/portal/shell/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/portal/shell/apps.py b/portal/shell/apps.py
new file mode 100644
index 0000000..b58e21b
--- /dev/null
+++ b/portal/shell/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ShellConfig(AppConfig):
+ name = "portal.shell"
+ label = "shell"
diff --git a/portal/shell/context.py b/portal/shell/context.py
new file mode 100644
index 0000000..c846371
--- /dev/null
+++ b/portal/shell/context.py
@@ -0,0 +1,37 @@
+from django.utils.translation import gettext_lazy as _
+
+from . import menu, preferences
+
+THEME_OPTIONS = (
+ ("light", _("Light")),
+ ("dark", _("Dark")),
+ ("system", _("System")),
+)
+LANGUAGE_OPTIONS = (("de", "Deutsch"), ("en", "English"))
+FORM_OPTIONS = (("sie", "Sie"), ("du", "du"))
+
+
+def initials(name: str) -> str:
+ words = [w for w in name.split() if w]
+ if not words:
+ return "?"
+ letters = words[0][0] + (words[-1][0] if len(words) > 1 else "")
+ return letters.upper()
+
+
+def shell(request):
+ """Menu, preferences and the values the base layout needs."""
+ prefs = getattr(request, "preferences", preferences.Preferences())
+ portal_user = getattr(request, "portal_user", None)
+ context = {
+ "preferences": prefs,
+ "html_lang": prefs.language,
+ "theme_options": THEME_OPTIONS,
+ "language_options": LANGUAGE_OPTIONS,
+ "form_options": FORM_OPTIONS,
+ "portal_user": portal_user,
+ }
+ if portal_user is not None:
+ context["menu_groups"] = menu.for_user(portal_user, request.path)
+ context["initials"] = initials(portal_user.display_name)
+ return context
diff --git a/portal/shell/menu.py b/portal/shell/menu.py
new file mode 100644
index 0000000..1292f04
--- /dev/null
+++ b/portal/shell/menu.py
@@ -0,0 +1,61 @@
+"""The left menu, declared once. An entry is shown only to users who may open its page."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+
+@dataclass(frozen=True)
+class Entry:
+ label: object # lazy translated string
+ url_name: str
+
+
+@dataclass(frozen=True)
+class Group:
+ title: object | None
+ role: str | None # None: everyone; otherwise "publisher" or "admin"
+ entries: tuple[Entry, ...]
+
+
+MENU = (
+ Group(None, None, (Entry(_("Home"), "home"),)),
+ Group(
+ _("Publishing"),
+ "publisher",
+ (Entry(_("Manage announcements"), "publish-announcements"),),
+ ),
+ Group(
+ _("Administration"),
+ "admin",
+ (
+ Entry(_("Appearance"), "admin-appearance"),
+ Entry(_("Settings"), "admin-settings"),
+ Entry(_("Audit log"), "admin-audit"),
+ Entry(_("Status"), "admin-status"),
+ ),
+ ),
+)
+
+
+def _allowed(group: Group, portal_user) -> bool:
+ if group.role is None:
+ return True
+ return bool(getattr(portal_user, f"is_{group.role}", False))
+
+
+def for_user(portal_user, current_path: str) -> list[dict]:
+ """The groups this user sees, each entry with its address and whether it is current."""
+ groups = []
+ for group in MENU:
+ if not _allowed(group, portal_user):
+ continue
+ entries = []
+ for entry in group.entries:
+ url = reverse(entry.url_name)
+ entries.append({"label": entry.label, "url": url, "current": url == current_path})
+ groups.append({"title": group.title, "entries": entries})
+ return groups
diff --git a/portal/shell/middleware.py b/portal/shell/middleware.py
new file mode 100644
index 0000000..bdf70fb
--- /dev/null
+++ b/portal/shell/middleware.py
@@ -0,0 +1,24 @@
+from django.utils import translation
+from django.utils.cache import patch_vary_headers
+
+from . import preferences
+
+
+class PreferencesMiddleware:
+ """Read the preference cookies and activate the language for this request (AD-15)."""
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ prefs = preferences.from_request(request)
+ request.preferences = prefs
+ request.LANGUAGE_CODE = prefs.language_code
+ translation.activate(prefs.language_code)
+ try:
+ response = self.get_response(request)
+ finally:
+ translation.deactivate()
+ response.headers.setdefault("Content-Language", prefs.language)
+ patch_vary_headers(response, ("Cookie", "Accept-Language"))
+ return response
diff --git a/portal/shell/preferences.py b/portal/shell/preferences.py
new file mode 100644
index 0000000..0e35f4d
--- /dev/null
+++ b/portal/shell/preferences.py
@@ -0,0 +1,82 @@
+"""Per-device preferences: theme, language and German form of address (AD-15).
+
+They live in cookies and are never stored on the server. Values are checked against fixed
+lists, so a forged cookie can only fall back to the default.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from django.http import HttpRequest, HttpResponse
+from django.utils import translation
+
+THEME_COOKIE = "pm_theme"
+LANGUAGE_COOKIE = "pm_lang"
+FORM_COOKIE = "pm_form"
+COOKIE_MAX_AGE = 365 * 24 * 60 * 60
+
+THEMES = ("light", "dark", "system")
+LANGUAGES = ("de", "en")
+FORMS = ("sie", "du")
+
+DEFAULT_THEME = "system"
+DEFAULT_FORM = "sie"
+INFORMAL_GERMAN = "de-du" # catalogue with only the informal differences; falls back to "de"
+
+_CHOICES = {
+ THEME_COOKIE: ("theme", THEMES),
+ LANGUAGE_COOKIE: ("language", LANGUAGES),
+ FORM_COOKIE: ("form", FORMS),
+}
+
+
+@dataclass(frozen=True)
+class Preferences:
+ theme: str = DEFAULT_THEME
+ language: str = "en"
+ form: str = DEFAULT_FORM
+
+ @property
+ def language_code(self) -> str:
+ """The code the translations are activated with."""
+ if self.language == "de" and self.form == "du":
+ return INFORMAL_GERMAN
+ return self.language
+
+
+def from_request(request: HttpRequest) -> Preferences:
+ theme = request.COOKIES.get(THEME_COOKIE)
+ language = request.COOKIES.get(LANGUAGE_COOKIE)
+ form = request.COOKIES.get(FORM_COOKIE)
+ if language not in LANGUAGES:
+ # Default from Accept-Language: German or English, English otherwise.
+ language = translation.get_language_from_request(request, check_path=False)
+ language = language if language in LANGUAGES else "en"
+ return Preferences(
+ theme=theme if theme in THEMES else DEFAULT_THEME,
+ language=language,
+ form=form if form in FORMS else DEFAULT_FORM,
+ )
+
+
+def chosen(values) -> dict[str, str]:
+ """The cookie name and value for every valid preference in `values` (a POST or a dict)."""
+ result = {}
+ for cookie, (field, allowed) in _CHOICES.items():
+ value = values.get(field)
+ if value in allowed:
+ result[cookie] = value
+ return result
+
+
+def set_cookies(response: HttpResponse, cookies: dict[str, str], *, secure: bool) -> None:
+ for name, value in cookies.items():
+ response.set_cookie(
+ name,
+ value,
+ max_age=COOKIE_MAX_AGE,
+ secure=secure,
+ httponly=False, # shell.js applies a change without a reload
+ samesite="Lax",
+ )
diff --git a/portal/shell/urls.py b/portal/shell/urls.py
new file mode 100644
index 0000000..d85f940
--- /dev/null
+++ b/portal/shell/urls.py
@@ -0,0 +1,12 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path("preferences", views.set_preferences, name="preferences"),
+ path("publish/announcements", views.publish_announcements, name="publish-announcements"),
+ path("administration/appearance", views.admin_appearance, name="admin-appearance"),
+ path("administration/settings", views.admin_settings, name="admin-settings"),
+ path("administration/audit", views.admin_audit, name="admin-audit"),
+ path("administration/status", views.admin_status, name="admin-status"),
+]
diff --git a/portal/shell/views.py b/portal/shell/views.py
new file mode 100644
index 0000000..5a3ed59
--- /dev/null
+++ b/portal/shell/views.py
@@ -0,0 +1,37 @@
+from django.conf import settings
+from django.http import HttpResponseRedirect
+from django.shortcuts import render
+from django.utils.http import url_has_allowed_host_and_scheme
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.http import require_POST
+
+from portal.accounts.decorators import admin_required, publisher_required
+
+from . import preferences
+
+
+@require_POST
+def set_preferences(request):
+ """Store the chosen preferences in cookies (the no-JavaScript path of the profile menu)."""
+ target = request.POST.get("next", "")
+ if not (target.startswith("/") and url_has_allowed_host_and_scheme(target, None)):
+ target = "/"
+ response = HttpResponseRedirect(target)
+ preferences.set_cookies(
+ response, preferences.chosen(request.POST), secure=not settings.DEBUG
+ )
+ return response
+
+
+def _placeholder(title):
+ def view(request):
+ return render(request, "shell/placeholder.html", {"title": title})
+
+ return view
+
+
+publish_announcements = publisher_required(_placeholder(_("Manage announcements")))
+admin_appearance = admin_required(_placeholder(_("Appearance")))
+admin_settings = admin_required(_placeholder(_("Settings")))
+admin_audit = admin_required(_placeholder(_("Audit log")))
+admin_status = admin_required(_placeholder(_("Status")))
diff --git a/portal/static/portal/shell.css b/portal/static/portal/shell.css
new file mode 100644
index 0000000..23dcc6f
--- /dev/null
+++ b/portal/static/portal/shell.css
@@ -0,0 +1,432 @@
+/* Page shell: design tokens from DESIGN.md, light and dark, wide and phone layout (AD-15). */
+
+:root {
+ --brand: #0f6cbd;
+ --on-brand: #fff;
+ --top-bar: var(--brand);
+ --on-top-bar: var(--on-brand);
+ --avatar-bg: #fff;
+ --avatar-fg: var(--brand);
+ --bg: #fafafa;
+ --surface: #fff;
+ --nav-surface: #f5f5f5;
+ --text: #242424;
+ --text-secondary: #616161;
+ --line: #e0e0e0;
+ --control-line: #8a8a8a;
+ --font: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
+ --radius-sm: 4px;
+ --radius-lg: 8px;
+ --touch: 44px;
+ --nav-width: 220px;
+ --bar-height: 48px;
+ color-scheme: light;
+}
+
+:root[data-theme="dark"] {
+ --brand: #479ef5;
+ --on-brand: #0a0a0a;
+ --top-bar: #0f2a43;
+ --on-top-bar: #fff;
+ --avatar-bg: #479ef5;
+ --avatar-fg: #0a0a0a;
+ --bg: #1f1f1f;
+ --surface: #292929;
+ --nav-surface: #242424;
+ --text: #fff;
+ --text-secondary: #c8c8c8;
+ --line: #3d3d3d;
+ --control-line: #9a9a9a;
+ color-scheme: dark;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root[data-theme="system"] {
+ --brand: #479ef5;
+ --on-brand: #0a0a0a;
+ --top-bar: #0f2a43;
+ --on-top-bar: #fff;
+ --avatar-bg: #479ef5;
+ --avatar-fg: #0a0a0a;
+ --bg: #1f1f1f;
+ --surface: #292929;
+ --nav-surface: #242424;
+ --text: #fff;
+ --text-secondary: #c8c8c8;
+ --line: #3d3d3d;
+ --control-line: #9a9a9a;
+ color-scheme: dark;
+ }
+}
+
+/* The tokens the Fluent avatar reads, mapped to the portal's own. */
+:root {
+ --colorBrandBackgroundStatic: var(--avatar-bg);
+ --colorNeutralForegroundStaticInverted: var(--avatar-fg);
+ --colorNeutralBackground1: var(--surface);
+ --colorNeutralBackground6: var(--nav-surface);
+ --colorNeutralForeground3: var(--text-secondary);
+ --fontFamilyBase: var(--font);
+ --fontWeightRegular: 400;
+ --fontWeightSemibold: 600;
+ --fontSizeBase100: 10px;
+ --fontSizeBase200: 12px;
+ --fontSizeBase300: 14px;
+ --fontSizeBase400: 16px;
+ --fontSizeBase500: 20px;
+ --fontSizeBase600: 24px;
+ --borderRadiusSmall: 2px;
+ --borderRadiusMedium: 4px;
+ --borderRadiusLarge: 6px;
+ --borderRadiusXLarge: 8px;
+ --borderRadiusCircular: 9999px;
+ --strokeWidthThin: 1px;
+ --strokeWidthThick: 2px;
+ --strokeWidthThicker: 3px;
+ --strokeWidthThickest: 4px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font: 400 14px/1.45 var(--font);
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+a {
+ color: var(--brand);
+}
+
+:focus-visible {
+ outline: 2px solid var(--brand);
+ outline-offset: 2px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.skip-link {
+ position: absolute;
+ left: 8px;
+ top: -100px;
+ z-index: 20;
+ padding: 10px 14px;
+ background: var(--surface);
+ color: var(--text);
+ border: 2px solid var(--brand);
+ border-radius: var(--radius-sm);
+}
+
+.skip-link:focus {
+ top: 8px;
+}
+
+/* Top bar */
+
+.top-bar {
+ position: relative;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ height: var(--bar-height);
+ padding: 0 16px;
+ background: var(--top-bar);
+ color: var(--on-top-bar);
+}
+
+.portal-name {
+ flex: 1;
+ color: inherit;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.menu-button {
+ display: none;
+ width: var(--touch);
+ height: var(--touch);
+ margin-left: -10px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ place-items: center;
+}
+
+.burger,
+.burger::before,
+.burger::after {
+ display: block;
+ width: 18px;
+ height: 2px;
+ background: currentcolor;
+ content: "";
+}
+
+.burger {
+ position: relative;
+}
+
+.burger::before {
+ position: absolute;
+ top: -6px;
+}
+
+.burger::after {
+ position: absolute;
+ top: 6px;
+}
+
+/* Profile menu */
+
+.profile {
+ position: relative;
+}
+
+.profile summary {
+ display: grid;
+ place-items: center;
+ width: var(--touch);
+ height: var(--touch);
+ margin-right: -6px;
+ list-style: none;
+ cursor: pointer;
+ border-radius: 50%;
+}
+
+.profile summary::-webkit-details-marker {
+ display: none;
+}
+
+/* The plain initials show until the Fluent avatar is defined. */
+.profile fluent-avatar:not(:defined) {
+ display: none;
+}
+
+.profile fluent-avatar:defined + .avatar-fallback {
+ display: none;
+}
+
+.avatar-fallback {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--avatar-bg);
+ color: var(--avatar-fg);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.profile-menu {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 2px);
+ width: 260px;
+ max-width: calc(100vw - 24px);
+ padding: 14px;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
+}
+
+.profile-name {
+ margin: 0;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.profile-user {
+ margin: 0 0 8px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ overflow-wrap: anywhere;
+}
+
+.prefs fieldset {
+ margin: 0;
+ padding: 8px 0 0;
+ border: 0;
+ border-top: 1px solid var(--line);
+}
+
+.prefs legend {
+ padding: 0;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.choices {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 6px 0 8px;
+}
+
+.profile-menu button {
+ min-height: var(--touch);
+ padding: 0 14px;
+ border: 1px solid var(--control-line);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ cursor: pointer;
+}
+
+.profile-menu button[aria-pressed="true"] {
+ background: var(--brand);
+ border-color: var(--brand);
+ color: var(--on-brand);
+ font-weight: 600;
+}
+
+.sign-out {
+ margin-top: 4px;
+ padding-top: 10px;
+ border-top: 1px solid var(--line);
+}
+
+/* Layout */
+
+.layout {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+#menu {
+ width: var(--nav-width);
+ flex: none;
+ padding: 12px 8px;
+ background: var(--nav-surface);
+ border-right: 1px solid var(--line);
+}
+
+.menu-heading {
+ margin: 14px 12px 4px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+#menu a {
+ display: flex;
+ align-items: center;
+ min-height: 38px;
+ padding: 0 12px;
+ margin-bottom: 2px;
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ text-decoration: none;
+}
+
+#menu a:hover {
+ background: var(--surface);
+}
+
+#menu a.current {
+ background: var(--surface);
+ font-weight: 600;
+ box-shadow: inset 3px 0 0 var(--brand);
+}
+
+main {
+ flex: 1;
+ min-width: 0;
+ padding: 24px 28px;
+}
+
+main:focus {
+ outline: none;
+}
+
+h1 {
+ margin: 0 0 4px;
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+.lead {
+ max-width: 640px;
+ margin: 0 0 18px;
+ color: var(--text-secondary);
+}
+
+main p,
+main li {
+ max-width: 640px;
+}
+
+main button {
+ min-height: var(--touch);
+ padding: 0 16px;
+ border: 1px solid var(--control-line);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text);
+ font: inherit;
+ cursor: pointer;
+}
+
+/* Phone: content only, the menu opens from the menu button. */
+
+@media (width <= 720px) {
+ main {
+ padding: 16px;
+ }
+
+ h1 {
+ font-size: 20px;
+ }
+
+ .layout {
+ flex-direction: column;
+ }
+
+ #menu {
+ width: auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ #menu a {
+ min-height: var(--touch);
+ }
+
+ /* Without JavaScript the menu stays visible above the content. */
+ .js .menu-button {
+ display: grid;
+ }
+
+ .js #menu {
+ display: none;
+ }
+
+ .js.menu-open #menu {
+ display: block;
+ }
+}
diff --git a/portal/static/portal/shell.js b/portal/static/portal/shell.js
new file mode 100644
index 0000000..529020e
--- /dev/null
+++ b/portal/static/portal/shell.js
@@ -0,0 +1,73 @@
+// Page shell behaviour: menu button, profile menu and preferences. Everything also works
+// without this script (links, a plain form), so it only makes the page quicker.
+(function () {
+ "use strict";
+
+ var root = document.documentElement;
+ root.classList.add("js");
+
+ var YEAR = 365 * 24 * 60 * 60;
+ var COOKIES = { theme: "pm_theme", language: "pm_lang", form: "pm_form" };
+ var ALLOWED = {
+ theme: ["light", "dark", "system"],
+ language: ["de", "en"],
+ form: ["sie", "du"],
+ };
+
+ function writeCookie(field, value) {
+ var secure = location.protocol === "https:" ? "; Secure" : "";
+ document.cookie =
+ COOKIES[field] + "=" + value + "; Max-Age=" + YEAR + "; Path=/; SameSite=Lax" + secure;
+ }
+
+ var profile = document.querySelector(".profile");
+ var menuButton = document.querySelector(".menu-button");
+
+ document.addEventListener("click", function (event) {
+ var button = event.target.closest("button[data-pref]");
+ if (button) {
+ var field = button.getAttribute("data-pref");
+ var value = button.value;
+ if (ALLOWED[field] && ALLOWED[field].indexOf(value) !== -1) {
+ event.preventDefault();
+ writeCookie(field, value);
+ if (field === "theme") {
+ root.setAttribute("data-theme", value);
+ button
+ .closest(".choices")
+ .querySelectorAll("button")
+ .forEach(function (other) {
+ other.setAttribute("aria-pressed", other === button ? "true" : "false");
+ });
+ } else {
+ location.reload(); // language and form change the text, which the server renders
+ }
+ }
+ return;
+ }
+ if (profile && profile.open && !profile.contains(event.target)) {
+ profile.open = false;
+ }
+ });
+
+ if (menuButton) {
+ menuButton.addEventListener("click", function () {
+ var open = root.classList.toggle("menu-open");
+ menuButton.setAttribute("aria-expanded", open ? "true" : "false");
+ });
+ }
+
+ document.addEventListener("keydown", function (event) {
+ if (event.key !== "Escape") {
+ return;
+ }
+ if (profile && profile.open) {
+ profile.open = false;
+ profile.querySelector("summary").focus();
+ } else if (menuButton && root.classList.contains("menu-open")) {
+ root.classList.remove("menu-open");
+ menuButton.setAttribute("aria-expanded", "false");
+ menuButton.focus();
+ }
+ });
+})();
diff --git a/portal/templates/accounts/base.html b/portal/templates/accounts/base.html
index 3d6dee6..94d9808 100644
--- a/portal/templates/accounts/base.html
+++ b/portal/templates/accounts/base.html
@@ -1,13 +1 @@
-{% load i18n %}
-
-
-
-
-{% block title %}{% trans "Self-service portal" %}{% endblock %}
-
-
-
-{% block content %}{% endblock %}
-
-
-
+{% extends "base.html" %}
diff --git a/portal/templates/accounts/home.html b/portal/templates/accounts/home.html
index d67325b..6f1dfb5 100644
--- a/portal/templates/accounts/home.html
+++ b/portal/templates/accounts/home.html
@@ -1,11 +1,7 @@
{% extends "accounts/base.html" %}
{% load i18n %}
-{% block title %}{% trans "Welcome" %}{% endblock %}
+{% block title %}{% trans "Home" %}{% endblock %}
{% block content %}
-{% blocktrans with name=portal_user.display_name %}Signed in as {{ name }}{% endblocktrans %}
-{{ portal_user.user.sam }}
-
+{% blocktrans with name=portal_user.display_name %}Hello, {{ name }}{% endblocktrans %}
+{% trans "You are signed in. More pages will follow here." %}
{% endblock %}
diff --git a/portal/templates/base.html b/portal/templates/base.html
new file mode 100644
index 0000000..664fb3c
--- /dev/null
+++ b/portal/templates/base.html
@@ -0,0 +1,69 @@
+{% load i18n static %}
+
+
+
+
+
+{% block title %}ping-mee Portal{% endblock %}
+
+
+
+
+
+{% trans "Skip to content" %}
+
+ {% if portal_user %}
+
+ {% endif %}
+ ping-mee Portal
+ {% if portal_user %}
+
+
+
+ {{ initials }}
+
+
+
+ {% endif %}
+
+
+ {% if portal_user %}
+
+ {% endif %}
+
+{% block content %}{% endblock %}
+
+
+
+
diff --git a/portal/templates/shell/placeholder.html b/portal/templates/shell/placeholder.html
new file mode 100644
index 0000000..6da8abf
--- /dev/null
+++ b/portal/templates/shell/placeholder.html
@@ -0,0 +1,7 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% block title %}{{ title }}{% endblock %}
+{% block content %}
+{{ title }}
+{% trans "This page is not available yet." %}
+{% endblock %}
diff --git a/portal/templates/shell/preference_group.html b/portal/templates/shell/preference_group.html
new file mode 100644
index 0000000..e398a89
--- /dev/null
+++ b/portal/templates/shell/preference_group.html
@@ -0,0 +1,8 @@
+
diff --git a/portal/tests/conftest.py b/portal/tests/conftest.py
index 700cbb4..c3e8690 100644
--- a/portal/tests/conftest.py
+++ b/portal/tests/conftest.py
@@ -1,5 +1,7 @@
+from pathlib import Path
from types import SimpleNamespace
+import polib
import pytest
from portal.adapters import registry
@@ -36,3 +38,19 @@ def adapters():
registry.install(fakes.directory, fakes.idp)
yield fakes
registry.install()
+
+
+@pytest.fixture(scope="session", autouse=True)
+def compiled_messages():
+ """The image compiles the catalogues with gettext; tests compile their own copy."""
+ for po_file in (Path(__file__).resolve().parent.parent / "locale").glob("*/LC_MESSAGES/*.po"):
+ polib.pofile(str(po_file)).save_as_mofile(str(po_file.with_suffix(".mo")))
+
+
+@pytest.fixture(autouse=True)
+def plain_static_files(settings):
+ """No collectstatic in tests, so use storage without a manifest."""
+ settings.STORAGES = {
+ **settings.STORAGES,
+ "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
+ }
diff --git a/portal/tests/test_preferences.py b/portal/tests/test_preferences.py
new file mode 100644
index 0000000..eb1d4f0
--- /dev/null
+++ b/portal/tests/test_preferences.py
@@ -0,0 +1,88 @@
+"""Per-device preferences: cookies in, validated values out (AD-15)."""
+
+import pytest
+from django.test import Client, RequestFactory
+
+from portal.shell import preferences
+from portal.shell.preferences import Preferences
+
+
+def request_with(cookies=None, accept_language=""):
+ request = RequestFactory().get("/", HTTP_ACCEPT_LANGUAGE=accept_language)
+ request.COOKIES.update(cookies or {})
+ return request
+
+
+def test_without_cookies_the_defaults_apply():
+ prefs = preferences.from_request(request_with())
+ assert prefs == Preferences(theme="system", language="en", form="sie")
+
+
+@pytest.mark.parametrize(
+ ("header", "expected"),
+ [("de-DE,de;q=0.9,en;q=0.5", "de"), ("en-GB", "en"), ("fr-FR", "en"), ("", "en")],
+)
+def test_the_language_defaults_from_the_browser(header, expected):
+ assert preferences.from_request(request_with(accept_language=header)).language == expected
+
+
+def test_valid_cookies_are_used_and_beat_the_browser():
+ cookies = {"pm_theme": "dark", "pm_lang": "en", "pm_form": "du"}
+ prefs = preferences.from_request(request_with(cookies, accept_language="de"))
+ assert prefs == Preferences(theme="dark", language="en", form="du")
+
+
+@pytest.mark.parametrize("value", ["", "blue", "')[0].content.decode()
+ assert "