feat: add theme selector to unauthenticated pages
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-03-13 14:48:10 +01:00
parent 99a8d64344
commit 104faf7006
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
6 changed files with 98 additions and 69 deletions

View file

@ -81,7 +81,7 @@ If the `<.header>` is outside the `<.form>`, the submit button must reference th
Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component:
- **Component:** `Layouts.public_page` renders:
- **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector (right)
- **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right)
- Main content slot, Flash group. No sidebar, no authenticated-layout logic.
- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`).
- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync.
@ -98,16 +98,18 @@ Use these standard roles:
| Role | Use | Class |
|---|---|---|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
| Subtitle | helper under title | `text-sm text-base-content/70` |
| Subtitle | helper under title | `text-sm text-base-content/85` |
| Section title (H2) | section headings | `text-lg font-semibold` |
| Helper text | under inputs | `text-sm text-base-content/70` |
| Fine print | small hints | `text-xs text-base-content/60` |
| Empty state | no data | `text-base-content/60 italic` |
| Helper text | under inputs | `text-sm text-base-content/85` |
| Fine print | small hints | `text-xs text-base-content/80` |
| Empty state | no data | `text-base-content/80 italic` |
| Destructive text | danger | `text-error` |
**MUST:** Page titles via `<.header>`.
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly deemphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `<span class="label-text">` as usual; no extra classes needed.
---
## 4) States: Loading, Empty, Error (mandatory consistency)

View file

@ -154,6 +154,14 @@
background-color: var(--color-base-100);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
which fails contrast. Override to 85% of base-content so labels stay slightly
deemphasised vs body text but meet the minimum ratio. */
[data-theme="light"] .label,
[data-theme="dark"] .label {
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
Theme tokens *-content are often too light on * backgrounds in light theme, and
badge-soft uses variant as text on a light tint (low contrast). We override

View file

@ -1295,6 +1295,41 @@ defmodule MvWeb.CoreComponents do
"""
end
@doc """
Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect).
Wired to the theme script in root layout: checkbox uses `data-theme-toggle`,
root script syncs checked state (checked = dark) and listens for `phx:set-theme`.
Use in public header or sidebar. Optional `class` is applied to the wrapper.
"""
attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper"
def theme_swap(assigns) do
assigns = assign(assigns, :wrapper_class, assigns[:class])
~H"""
<div class={[@wrapper_class]}>
<label
class="swap swap-rotate cursor-pointer focus-within:outline-none focus-within:focus-visible:ring-2 focus-within:focus-visible:ring-primary focus-within:focus-visible:ring-offset-2 rounded"
aria-label={gettext("Toggle dark mode")}
>
<input
type="checkbox"
data-theme-toggle
aria-label={gettext("Toggle dark mode")}
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
/>
<span class="swap-on size-6 flex items-center justify-center" aria-hidden="true">
<.icon name="hero-moon" class="size-5" />
</span>
<span class="swap-off size-6 flex items-center justify-center" aria-hidden="true">
<.icon name="hero-sun" class="size-5" />
</span>
</label>
</div>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).

View file

@ -39,18 +39,21 @@ defmodule MvWeb.Layouts do
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<div class="shrink-0 flex items-center gap-2">
<form method="post" action={~p"/set_locale"}>
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<.theme_swap />
</div>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto max-full space-y-4">
@ -156,18 +159,21 @@ defmodule MvWeb.Layouts do
<span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<div class="shrink-0 flex items-center gap-2">
<form method="post" action={~p"/set_locale"}>
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<.theme_swap />
</div>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">

View file

@ -251,21 +251,22 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_footer(assigns) do
~H"""
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
<!-- Language Selector (nur expanded) -->
<form method="post" action={~p"/set_locale"} class="expanded-only">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
<!-- Theme Toggle (immer sichtbar) -->
<.theme_toggle />
<!-- Theme swap + Language selector in one row (theme left, language right when expanded) -->
<div class="flex items-center gap-2">
<.theme_swap />
<form method="post" action={~p"/set_locale"} class="expanded-only flex-1 min-w-0">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</select>
</form>
</div>
<!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %>
<.user_menu current_user={@current_user} />
@ -274,29 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
"""
end
defp theme_toggle(assigns) do
~H"""
<label
class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
aria-label={gettext("Toggle dark mode")}
>
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
<div id="theme-toggle" phx-update="ignore">
<input
id="theme-toggle-input"
type="checkbox"
class="toggle toggle-sm focus:outline-none"
data-theme-toggle
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
aria-label={gettext("Toggle dark mode")}
/>
</div>
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
</label>
"""
end
attr :current_user, :map, default: nil, doc: "The current user"
defp user_menu(assigns) do

View file

@ -100,13 +100,13 @@ defmodule MvWeb.JoinLive do
/>
</div>
<p class="text-sm text-base-content/70">
<p class="text-sm text-base-content/85">
{gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)}
</p>
<p class="text-xs text-base-content/60">
<p class="text-xs text-base-content/80">
{gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)}