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: 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: - **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. - 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`). - **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. - **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 | | Role | Use | Class |
|---|---|---| |---|---|---|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` | | 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` | | Section title (H2) | section headings | `text-lg font-semibold` |
| Helper text | under inputs | `text-sm text-base-content/70` | | Helper text | under inputs | `text-sm text-base-content/85` |
| Fine print | small hints | `text-xs text-base-content/60` | | Fine print | small hints | `text-xs text-base-content/80` |
| Empty state | no data | `text-base-content/60 italic` | | Empty state | no data | `text-base-content/80 italic` |
| Destructive text | danger | `text-error` | | Destructive text | danger | `text-error` |
**MUST:** Page titles via `<.header>`. **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). **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) ## 4) States: Loading, Empty, Error (mandatory consistency)

View file

@ -154,6 +154,14 @@
background-color: var(--color-base-100); 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. /* 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 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 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 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 """ @doc """
Renders a [Heroicon](https://heroicons.com). 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"> <span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name} {@club_name}
</span> </span>
<form method="post" action={~p"/set_locale"} class="shrink-0"> <div class="shrink-0 flex items-center gap-2">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} /> <form method="post" action={~p"/set_locale"}>
<select <input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
name="locale" <select
onchange="this.form.submit()" name="locale"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" onchange="this.form.submit()"
aria-label={gettext("Select language")} 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> <option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
</select> <option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</form> </select>
</form>
<.theme_swap />
</div>
</header> </header>
<main class="px-4 py-8 sm:px-6"> <main class="px-4 py-8 sm:px-6">
<div class="mx-auto max-full space-y-4"> <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"> <span class="absolute left-1/2 -translate-x-1/2 text-lg font-bold text-center max-w-[50%] truncate">
{@club_name} {@club_name}
</span> </span>
<form method="post" action={~p"/set_locale"} class="shrink-0"> <div class="shrink-0 flex items-center gap-2">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} /> <form method="post" action={~p"/set_locale"}>
<select <input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
name="locale" <select
onchange="this.form.submit()" name="locale"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" onchange="this.form.submit()"
aria-label={gettext("Select language")} 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> <option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
</select> <option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
</form> </select>
</form>
<.theme_swap />
</div>
</header> </header>
<main class="px-4 py-8 sm:px-6"> <main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full"> <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 defp sidebar_footer(assigns) do
~H""" ~H"""
<div class="mt-auto p-4 border-t border-base-300 space-y-4"> <div class="mt-auto p-4 border-t border-base-300 space-y-4">
<!-- Language Selector (nur expanded) --> <!-- Theme swap + Language selector in one row (theme left, language right when expanded) -->
<form method="post" action={~p"/set_locale"} class="expanded-only"> <div class="flex items-center gap-2">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} /> <.theme_swap />
<select <form method="post" action={~p"/set_locale"} class="expanded-only flex-1 min-w-0">
name="locale" <input type="hidden" name="_csrf_token" value={get_csrf_token()} />
onchange="this.form.submit()" <select
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" name="locale"
aria-label={gettext("Select language")} onchange="this.form.submit()"
> class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
<option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option> aria-label={gettext("Select language")}
<option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option> >
</select> <option value="de" selected={Gettext.get_locale(MvWeb.Gettext) == "de"}>Deutsch</option>
</form> <option value="en" selected={Gettext.get_locale(MvWeb.Gettext) == "en"}>English</option>
<!-- Theme Toggle (immer sichtbar) --> </select>
<.theme_toggle /> </form>
</div>
<!-- User Menu (nur wenn current_user existiert) --> <!-- User Menu (nur wenn current_user existiert) -->
<%= if @current_user do %> <%= if @current_user do %>
<.user_menu current_user={@current_user} /> <.user_menu current_user={@current_user} />
@ -274,29 +275,6 @@ defmodule MvWeb.Layouts.Sidebar do
""" """
end 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" attr :current_user, :map, default: nil, doc: "The current user"
defp user_menu(assigns) do defp user_menu(assigns) do

View file

@ -100,13 +100,13 @@ defmodule MvWeb.JoinLive do
/> />
</div> </div>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/85">
{gettext( {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." "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>
<p class="text-xs text-base-content/60"> <p class="text-xs text-base-content/80">
{gettext( {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." "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."
)} )}