judu/ex-egopublic Fork
⑂ 90be069
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: thème contraste élevé par utilisateur (proposition Claude Design) — tokens HC dans style.css, palette de gris jumelle, sélecteur Affichage dans Réglages (FR/EN/DE), migration 0008Unverified

Claude-Session: https://claude.ai/code/session_01FPKHKGfqvnvXKY9tNhYmkh
Julien Durillon committed 2026-08-09T01:49:10+02:00 Browse files
90be069 parent: a32b594
modified assets/style.css +64 -0
@@ -50,6 +50,49 @@
5050 --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
5151 }
5252
53+/* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══
54+ Noir et blanc, source : proposition THEME_HC du projet Claude Design.
55+ Les ombres deviennent des liserés blancs (une ombre noire est invisible
56+ sur fond noir) ; les widgets natifs passent en sombre (color-scheme). */
57+:root[data-theme="hc"] {
58+ color-scheme: dark;
59+ --color-bg: #000000;
60+ --color-surface: #141414;
61+ --color-text: #ffffff;
62+ --color-accent: #ffffff;
63+ --color-accent-deep: #ffffff;
64+ --color-accent-2: #d9d9d9;
65+ --color-divider: rgba(255, 255, 255, .42);
66+
67+ --color-neutral-100: #0a0a0a;
68+ --color-neutral-200: #1c1c1c;
69+ --color-neutral-300: #000000;
70+ --color-neutral-500: #8a8a8a;
71+ --color-neutral-800: #e6e6e6;
72+ --color-neutral-900: #000000;
73+
74+ --color-accent-100: #0d0d0d;
75+ --color-accent-200: #1f1f1f;
76+ --color-accent-300: #4d4d4d;
77+ --color-accent-600: #e0e0e0;
78+ --color-accent-700: #ffffff;
79+ --color-accent-900: #ffffff;
80+
81+ --color-accent-2-200: #242424;
82+ --color-accent-2-900: #ffffff;
83+
84+ --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28);
85+ --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34);
86+ --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42);
87+}
88+/* Le toast s'inverse : son fond neutral-900 devient noir en HC. */
89+:root[data-theme="hc"] .toast { background: #ffffff; color: #000000; }
90+/* Rail de l'interrupteur : neutral-300 = noir en HC, un liseré le dessine. */
91+:root[data-theme="hc"] .swt { box-shadow: inset 0 0 0 1px var(--color-divider); }
92+/* Textes atténués des templates (inline) : estompe relevée, comme le design.
93+ !important : seule façon de surclasser un style inline. */
94+:root[data-theme="hc"] [style*="opacity:.7"] { opacity: .8 !important; }
95+
5396 *, *::before, *::after { box-sizing: border-box; }
5497 html { height: 100%; }
5598 body {
@@ -537,6 +580,27 @@ a.team-pill:hover { filter: brightness(.96); }
537580 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
538581 .done-sec .done-body { transition: opacity .15s ease; }
539582 .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; }
583+/* Sélecteur de thème (Réglages, section Affichage). */
584+.theme-choice {
585+ display: flex; align-items: center; gap: var(--space-3); width: 100%;
586+ padding: var(--space-3); border-radius: var(--radius-md); cursor: pointer;
587+ background: var(--color-surface); border: 1.5px solid transparent;
588+ color: var(--color-text); font: inherit; text-align: left;
589+}
590+.theme-choice:hover { border-color: var(--color-accent-2); }
591+.theme-choice.current {
592+ background: var(--color-accent-2-200); border-color: var(--color-accent-2);
593+ color: var(--color-accent-2-900);
594+}
595+/* Échantillons en couleurs littérales : chaque pastille montre SON thème,
596+ quel que soit le thème actif. */
597+.theme-swatch {
598+ width: 34px; height: 34px; flex: none; border-radius: var(--radius-md);
599+ border: 1px solid var(--color-divider);
600+}
601+.swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); }
602+.swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); }
603+
540604 /* Sélecteur de langue (Réglages). */
541605 .lang-pill {
542606 display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;
@@ -50,6 +50,49 @@
50 --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);50 --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
51 }51 }
52 52
53+/* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══
54+ Noir et blanc, source : proposition THEME_HC du projet Claude Design.
55+ Les ombres deviennent des liserés blancs (une ombre noire est invisible
56+ sur fond noir) ; les widgets natifs passent en sombre (color-scheme). */
57+:root[data-theme="hc"] {
58+ color-scheme: dark;
59+ --color-bg: #000000;
60+ --color-surface: #141414;
61+ --color-text: #ffffff;
62+ --color-accent: #ffffff;
63+ --color-accent-deep: #ffffff;
64+ --color-accent-2: #d9d9d9;
65+ --color-divider: rgba(255, 255, 255, .42);
66+
67+ --color-neutral-100: #0a0a0a;
68+ --color-neutral-200: #1c1c1c;
69+ --color-neutral-300: #000000;
70+ --color-neutral-500: #8a8a8a;
71+ --color-neutral-800: #e6e6e6;
72+ --color-neutral-900: #000000;
73+
74+ --color-accent-100: #0d0d0d;
75+ --color-accent-200: #1f1f1f;
76+ --color-accent-300: #4d4d4d;
77+ --color-accent-600: #e0e0e0;
78+ --color-accent-700: #ffffff;
79+ --color-accent-900: #ffffff;
80+
81+ --color-accent-2-200: #242424;
82+ --color-accent-2-900: #ffffff;
83+
84+ --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28);
85+ --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34);
86+ --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42);
87+}
88+/* Le toast s'inverse : son fond neutral-900 devient noir en HC. */
89+:root[data-theme="hc"] .toast { background: #ffffff; color: #000000; }
90+/* Rail de l'interrupteur : neutral-300 = noir en HC, un liseré le dessine. */
91+:root[data-theme="hc"] .swt { box-shadow: inset 0 0 0 1px var(--color-divider); }
92+/* Textes atténués des templates (inline) : estompe relevée, comme le design.
93+ !important : seule façon de surclasser un style inline. */
94+:root[data-theme="hc"] [style*="opacity:.7"] { opacity: .8 !important; }
95+
53 *, *::before, *::after { box-sizing: border-box; }96 *, *::before, *::after { box-sizing: border-box; }
54 html { height: 100%; }97 html { height: 100%; }
55 body {98 body {
@@ -537,6 +580,27 @@ a.team-pill:hover { filter: brightness(.96); }
537 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }580 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
538 .done-sec .done-body { transition: opacity .15s ease; }581 .done-sec .done-body { transition: opacity .15s ease; }
539 .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; }582 .done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; }
583+/* Sélecteur de thème (Réglages, section Affichage). */
584+.theme-choice {
585+ display: flex; align-items: center; gap: var(--space-3); width: 100%;
586+ padding: var(--space-3); border-radius: var(--radius-md); cursor: pointer;
587+ background: var(--color-surface); border: 1.5px solid transparent;
588+ color: var(--color-text); font: inherit; text-align: left;
589+}
590+.theme-choice:hover { border-color: var(--color-accent-2); }
591+.theme-choice.current {
592+ background: var(--color-accent-2-200); border-color: var(--color-accent-2);
593+ color: var(--color-accent-2-900);
594+}
595+/* Échantillons en couleurs littérales : chaque pastille montre SON thème,
596+ quel que soit le thème actif. */
597+.theme-swatch {
598+ width: 34px; height: 34px; flex: none; border-radius: var(--radius-md);
599+ border: 1px solid var(--color-divider);
600+}
601+.swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); }
602+.swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); }
603+
540 /* Sélecteur de langue (Réglages). */604 /* Sélecteur de langue (Réglages). */
541 .lang-pill {605 .lang-pill {
542 display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;606 display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;
added migrations/0008_user_theme.sql +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+-- Thème de l'interface, PAR UTILISATEUR (comme la langue) : le choix suit le
2+-- compte sur tous les appareils. 'organic' = style par défaut crème/terracotta,
3+-- 'hc' = contraste élevé noir et blanc (proposition du projet Claude Design).
4+ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'organic'
5+ CHECK (theme IN ('organic', 'hc'));
new file mode 100644
@@ -0,0 +1,5 @@
1+-- Thème de l'interface, PAR UTILISATEUR (comme la langue) : le choix suit le
2+-- compte sur tous les appareils. 'organic' = style par défaut crème/terracotta,
3+-- 'hc' = contraste élevé noir et blanc (proposition du projet Claude Design).
4+ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'organic'
5+ CHECK (theme IN ('organic', 'hc'));
modified src/charts.rs +29 -9
@@ -16,19 +16,34 @@ pub const PALETTE: [&str; 15] = [
1616 "#c98d8d",
1717 ];
1818
19+/// Palette du thème contraste élevé — mêmes rangs que PALETTE, en niveaux de
20+/// gris (les 7 premières valeurs viennent du projet Design, l'extension
21+/// alterne foncé/clair pour garder des voisins distinguables).
22+pub const PALETTE_HC: [&str; 15] = [
23+ "#ffffff", "#9e9e9e", "#d6d6d6", "#6b6b6b", "#c0c0c0", "#828282", "#ebebeb",
24+ "#494949", "#b0b0b0", "#767676", "#f5f5f5", "#5a5a5a", "#cbcbcb", "#8f8f8f",
25+ "#e1e1e1",
26+];
27+
28+fn palette(hc: bool) -> &'static [&'static str; 15] {
29+ if hc { &PALETTE_HC } else { &PALETTE }
30+}
31+
1932 /// category_id → couleur, à partir de la liste des tâches triée par id (création).
20-pub fn color_map(task_ids_in_creation_order: &[Uuid]) -> HashMap<Uuid, &'static str> {
33+pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> {
34+ let p = palette(hc);
2135 task_ids_in_creation_order
2236 .iter()
2337 .enumerate()
24- .map(|(i, id)| (*id, PALETTE[i % PALETTE.len()]))
38+ .map(|(i, id)| (*id, p[i % p.len()]))
2539 .collect()
2640 }
2741
2842 /// Couleur d'une équipe — dérivée du hash de son id (les bits bas d'un
2943 /// UUIDv7 sont aléatoires), jamais stockée en base.
30-pub fn team_color(team_id: Uuid) -> &'static str {
31- PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize]
44+pub fn team_color(team_id: Uuid, hc: bool) -> &'static str {
45+ let p = palette(hc);
46+ p[(team_id.as_u128() % p.len() as u128) as usize]
3247 }
3348
3449 /// « 45 min », « 2 h », « 12 h 30 »
@@ -216,7 +231,7 @@ mod tests {
216231 }
217232
218233 fn colors() -> HashMap<Uuid, &'static str> {
219- color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)])
234+ color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false)
220235 }
221236
222237 #[test]
@@ -260,16 +275,21 @@ mod tests {
260275 #[test]
261276 fn stable_colors_by_creation_order() {
262277 let ids = [Uuid::from_u128(5), Uuid::from_u128(3)];
263- let m = color_map(&ids);
278+ let m = color_map(&ids, false);
264279 assert_eq!(m[&ids[0]], PALETTE[0]);
265280 assert_eq!(m[&ids[1]], PALETTE[1]);
281+ // Même rang dans les deux palettes : passer en HC ne mélange rien.
282+ let hc = color_map(&ids, true);
283+ assert_eq!(hc[&ids[0]], PALETTE_HC[0]);
284+ assert_eq!(hc[&ids[1]], PALETTE_HC[1]);
266285 }
267286
268287 #[test]
269288 fn team_color_stable_and_in_palette() {
270289 let id = Uuid::from_u128(0x1234_5678_9abc_def0);
271- assert_eq!(team_color(id), team_color(id));
272- assert!(PALETTE.contains(&team_color(id)));
273- assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]);
290+ assert_eq!(team_color(id, false), team_color(id, false));
291+ assert!(PALETTE.contains(&team_color(id, false)));
292+ assert_eq!(team_color(Uuid::from_u128(8), false), PALETTE[8]);
293+ assert_eq!(team_color(Uuid::from_u128(8), true), PALETTE_HC[8]);
274294 }
275295 }
@@ -16,19 +16,34 @@ pub const PALETTE: [&str; 15] = [
16 "#c98d8d",16 "#c98d8d",
17 ];17 ];
18 18
19+/// Palette du thème contraste élevé — mêmes rangs que PALETTE, en niveaux de
20+/// gris (les 7 premières valeurs viennent du projet Design, l'extension
21+/// alterne foncé/clair pour garder des voisins distinguables).
22+pub const PALETTE_HC: [&str; 15] = [
23+ "#ffffff", "#9e9e9e", "#d6d6d6", "#6b6b6b", "#c0c0c0", "#828282", "#ebebeb",
24+ "#494949", "#b0b0b0", "#767676", "#f5f5f5", "#5a5a5a", "#cbcbcb", "#8f8f8f",
25+ "#e1e1e1",
26+];
27+
28+fn palette(hc: bool) -> &'static [&'static str; 15] {
29+ if hc { &PALETTE_HC } else { &PALETTE }
30+}
31+
19 /// category_id → couleur, à partir de la liste des tâches triée par id (création).32 /// category_id → couleur, à partir de la liste des tâches triée par id (création).
20-pub fn color_map(task_ids_in_creation_order: &[Uuid]) -> HashMap<Uuid, &'static str> {33+pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> {
34+ let p = palette(hc);
21 task_ids_in_creation_order35 task_ids_in_creation_order
22 .iter()36 .iter()
23 .enumerate()37 .enumerate()
24- .map(|(i, id)| (*id, PALETTE[i % PALETTE.len()]))38+ .map(|(i, id)| (*id, p[i % p.len()]))
25 .collect()39 .collect()
26 }40 }
27 41
28 /// Couleur d'une équipe — dérivée du hash de son id (les bits bas d'un42 /// Couleur d'une équipe — dérivée du hash de son id (les bits bas d'un
29 /// UUIDv7 sont aléatoires), jamais stockée en base.43 /// UUIDv7 sont aléatoires), jamais stockée en base.
30-pub fn team_color(team_id: Uuid) -> &'static str {44+pub fn team_color(team_id: Uuid, hc: bool) -> &'static str {
31- PALETTE[(team_id.as_u128() % PALETTE.len() as u128) as usize]45+ let p = palette(hc);
46+ p[(team_id.as_u128() % p.len() as u128) as usize]
32 }47 }
33 48
34 /// « 45 min », « 2 h », « 12 h 30 »49 /// « 45 min », « 2 h », « 12 h 30 »
@@ -216,7 +231,7 @@ mod tests {
216 }231 }
217 232
218 fn colors() -> HashMap<Uuid, &'static str> {233 fn colors() -> HashMap<Uuid, &'static str> {
219- color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)])234+ color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false)
220 }235 }
221 236
222 #[test]237 #[test]
@@ -260,16 +275,21 @@ mod tests {
260 #[test]275 #[test]
261 fn stable_colors_by_creation_order() {276 fn stable_colors_by_creation_order() {
262 let ids = [Uuid::from_u128(5), Uuid::from_u128(3)];277 let ids = [Uuid::from_u128(5), Uuid::from_u128(3)];
263- let m = color_map(&ids);278+ let m = color_map(&ids, false);
264 assert_eq!(m[&ids[0]], PALETTE[0]);279 assert_eq!(m[&ids[0]], PALETTE[0]);
265 assert_eq!(m[&ids[1]], PALETTE[1]);280 assert_eq!(m[&ids[1]], PALETTE[1]);
281+ // Même rang dans les deux palettes : passer en HC ne mélange rien.
282+ let hc = color_map(&ids, true);
283+ assert_eq!(hc[&ids[0]], PALETTE_HC[0]);
284+ assert_eq!(hc[&ids[1]], PALETTE_HC[1]);
266 }285 }
267 286
268 #[test]287 #[test]
269 fn team_color_stable_and_in_palette() {288 fn team_color_stable_and_in_palette() {
270 let id = Uuid::from_u128(0x1234_5678_9abc_def0);289 let id = Uuid::from_u128(0x1234_5678_9abc_def0);
271- assert_eq!(team_color(id), team_color(id));290+ assert_eq!(team_color(id, false), team_color(id, false));
272- assert!(PALETTE.contains(&team_color(id)));291+ assert!(PALETTE.contains(&team_color(id, false)));
273- assert_eq!(team_color(Uuid::from_u128(8)), PALETTE[8]);292+ assert_eq!(team_color(Uuid::from_u128(8), false), PALETTE[8]);
293+ assert_eq!(team_color(Uuid::from_u128(8), true), PALETTE_HC[8]);
274 }294 }
275 }295 }
modified src/db/users.rs +13 -3
@@ -37,9 +37,9 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq
3737 Ok(())
3838 }
3939
40-/// (username, code langue) — profil minimal chargé à chaque requête.
41-pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String)>> {
42- sqlx::query_as("SELECT username, lang FROM users WHERE id = $1")
40+/// (username, code langue, thème) — profil minimal chargé à chaque requête.
41+pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String, String)>> {
42+ sqlx::query_as("SELECT username, lang, theme FROM users WHERE id = $1")
4343 .bind(id)
4444 .fetch_optional(pool)
4545 .await
@@ -55,6 +55,16 @@ pub async fn set_lang(pool: &PgPool, id: Uuid, lang: &str) -> sqlx::Result<()> {
5555 Ok(())
5656 }
5757
58+/// Le CHECK en base garde le garde-fou final ('organic' | 'hc').
59+pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()> {
60+ sqlx::query("UPDATE users SET theme = $1 WHERE id = $2")
61+ .bind(theme)
62+ .bind(id)
63+ .execute(pool)
64+ .await?;
65+ Ok(())
66+}
67+
5868 /// Cible d'une invitation.
5969 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
6070 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
@@ -37,9 +37,9 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq
37 Ok(())37 Ok(())
38 }38 }
39 39
40-/// (username, code langue) — profil minimal chargé à chaque requête.40+/// (username, code langue, thème) — profil minimal chargé à chaque requête.
41-pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String)>> {41+pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String, String)>> {
42- sqlx::query_as("SELECT username, lang FROM users WHERE id = $1")42+ sqlx::query_as("SELECT username, lang, theme FROM users WHERE id = $1")
43 .bind(id)43 .bind(id)
44 .fetch_optional(pool)44 .fetch_optional(pool)
45 .await45 .await
@@ -55,6 +55,16 @@ pub async fn set_lang(pool: &PgPool, id: Uuid, lang: &str) -> sqlx::Result<()> {
55 Ok(())55 Ok(())
56 }56 }
57 57
58+/// Le CHECK en base garde le garde-fou final ('organic' | 'hc').
59+pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()> {
60+ sqlx::query("UPDATE users SET theme = $1 WHERE id = $2")
61+ .bind(theme)
62+ .bind(id)
63+ .execute(pool)
64+ .await?;
65+ Ok(())
66+}
67+
58 /// Cible d'une invitation.68 /// Cible d'une invitation.
59 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {69 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
60 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")70 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
modified src/i18n.rs +28 -0
@@ -194,6 +194,13 @@ pub struct Tr {
194194 pub leave_team_pat: &'static str,
195195 pub leave_note: &'static str,
196196 pub language: &'static str,
197+ pub display: &'static str,
198+ pub display_hint: &'static str,
199+ pub theme_organic: &'static str,
200+ pub theme_organic_desc: &'static str,
201+ pub theme_hc: &'static str,
202+ pub theme_hc_desc: &'static str,
203+ pub toast_theme_updated: &'static str,
197204 pub session: &'static str,
198205 pub current_password: &'static str,
199206 pub new_password: &'static str,
@@ -429,6 +436,13 @@ pub static FR: Tr = Tr {
429436 leave_team_pat: "Quitter « {} »",
430437 leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.",
431438 language: "Langue",
439+ display: "Affichage",
440+ display_hint: "Le mode contraste élevé passe l'application en noir et blanc : lisible sur un écran très sombre ou avec un filtre de lumière chaude.",
441+ theme_organic: "Organic",
442+ theme_organic_desc: "Crème et terracotta, le style par défaut.",
443+ theme_hc: "Contraste élevé",
444+ theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.",
445+ toast_theme_updated: "Affichage mis à jour",
432446 session: "Session",
433447 current_password: "Mot de passe actuel",
434448 new_password: "Nouveau mot de passe",
@@ -636,6 +650,13 @@ pub static EN: Tr = Tr {
636650 leave_team_pat: "Leave “{}”",
637651 leave_note: "Your recorded times stay in the team's stats.",
638652 language: "Language",
653+ display: "Display",
654+ display_hint: "High-contrast mode turns the app black and white: readable on a very dark screen or with a warm light filter.",
655+ theme_organic: "Organic",
656+ theme_organic_desc: "Cream and terracotta, the default style.",
657+ theme_hc: "High contrast",
658+ theme_hc_desc: "Black and white, readable in very low light or with a warm filter.",
659+ toast_theme_updated: "Display updated",
639660 session: "Session",
640661 current_password: "Current password",
641662 new_password: "New password",
@@ -843,6 +864,13 @@ pub static DE: Tr = Tr {
843864 leave_team_pat: "„{}“ verlassen",
844865 leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.",
845866 language: "Sprache",
867+ display: "Anzeige",
868+ display_hint: "Der Modus mit hohem Kontrast stellt die App in Schwarz-Weiß dar: gut lesbar auf einem sehr dunklen Bildschirm oder mit warmem Lichtfilter.",
869+ theme_organic: "Organic",
870+ theme_organic_desc: "Creme und Terrakotta, der Standard-Stil.",
871+ theme_hc: "Hoher Kontrast",
872+ theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.",
873+ toast_theme_updated: "Anzeige aktualisiert",
846874 session: "Sitzung",
847875 current_password: "Aktuelles Passwort",
848876 new_password: "Neues Passwort",
@@ -194,6 +194,13 @@ pub struct Tr {
194 pub leave_team_pat: &'static str,194 pub leave_team_pat: &'static str,
195 pub leave_note: &'static str,195 pub leave_note: &'static str,
196 pub language: &'static str,196 pub language: &'static str,
197+ pub display: &'static str,
198+ pub display_hint: &'static str,
199+ pub theme_organic: &'static str,
200+ pub theme_organic_desc: &'static str,
201+ pub theme_hc: &'static str,
202+ pub theme_hc_desc: &'static str,
203+ pub toast_theme_updated: &'static str,
197 pub session: &'static str,204 pub session: &'static str,
198 pub current_password: &'static str,205 pub current_password: &'static str,
199 pub new_password: &'static str,206 pub new_password: &'static str,
@@ -429,6 +436,13 @@ pub static FR: Tr = Tr {
429 leave_team_pat: "Quitter « {} »",436 leave_team_pat: "Quitter « {} »",
430 leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.",437 leave_note: "Tes temps déjà saisis restent dans les stats de l'équipe.",
431 language: "Langue",438 language: "Langue",
439+ display: "Affichage",
440+ display_hint: "Le mode contraste élevé passe l'application en noir et blanc : lisible sur un écran très sombre ou avec un filtre de lumière chaude.",
441+ theme_organic: "Organic",
442+ theme_organic_desc: "Crème et terracotta, le style par défaut.",
443+ theme_hc: "Contraste élevé",
444+ theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.",
445+ toast_theme_updated: "Affichage mis à jour",
432 session: "Session",446 session: "Session",
433 current_password: "Mot de passe actuel",447 current_password: "Mot de passe actuel",
434 new_password: "Nouveau mot de passe",448 new_password: "Nouveau mot de passe",
@@ -636,6 +650,13 @@ pub static EN: Tr = Tr {
636 leave_team_pat: "Leave “{}”",650 leave_team_pat: "Leave “{}”",
637 leave_note: "Your recorded times stay in the team's stats.",651 leave_note: "Your recorded times stay in the team's stats.",
638 language: "Language",652 language: "Language",
653+ display: "Display",
654+ display_hint: "High-contrast mode turns the app black and white: readable on a very dark screen or with a warm light filter.",
655+ theme_organic: "Organic",
656+ theme_organic_desc: "Cream and terracotta, the default style.",
657+ theme_hc: "High contrast",
658+ theme_hc_desc: "Black and white, readable in very low light or with a warm filter.",
659+ toast_theme_updated: "Display updated",
639 session: "Session",660 session: "Session",
640 current_password: "Current password",661 current_password: "Current password",
641 new_password: "New password",662 new_password: "New password",
@@ -843,6 +864,13 @@ pub static DE: Tr = Tr {
843 leave_team_pat: "„{}“ verlassen",864 leave_team_pat: "„{}“ verlassen",
844 leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.",865 leave_note: "Deine erfassten Zeiten bleiben in der Team-Statistik.",
845 language: "Sprache",866 language: "Sprache",
867+ display: "Anzeige",
868+ display_hint: "Der Modus mit hohem Kontrast stellt die App in Schwarz-Weiß dar: gut lesbar auf einem sehr dunklen Bildschirm oder mit warmem Lichtfilter.",
869+ theme_organic: "Organic",
870+ theme_organic_desc: "Creme und Terrakotta, der Standard-Stil.",
871+ theme_hc: "Hoher Kontrast",
872+ theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.",
873+ toast_theme_updated: "Anzeige aktualisiert",
846 session: "Sitzung",874 session: "Sitzung",
847 current_password: "Aktuelles Passwort",875 current_password: "Aktuelles Passwort",
848 new_password: "Neues Passwort",876 new_password: "Neues Passwort",
modified src/render.rs +2 -0
@@ -53,6 +53,8 @@ pub struct Shell {
5353 pub team_name: String,
5454 /// Couleur de l'équipe active (teinte de la pill d'en-tête).
5555 pub team_color: &'static str,
56+ /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>).
57+ pub hc: bool,
5658 /// "add" | "stats" | "times" | "settings"
5759 pub tab: &'static str,
5860 pub toast: Option<String>,
@@ -53,6 +53,8 @@ pub struct Shell {
53 pub team_name: String,53 pub team_name: String,
54 /// Couleur de l'équipe active (teinte de la pill d'en-tête).54 /// Couleur de l'équipe active (teinte de la pill d'en-tête).
55 pub team_color: &'static str,55 pub team_color: &'static str,
56+ /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>).
57+ pub hc: bool,
56 /// "add" | "stats" | "times" | "settings"58 /// "add" | "stats" | "times" | "settings"
57 pub tab: &'static str,59 pub tab: &'static str,
58 pub toast: Option<String>,60 pub toast: Option<String>,
modified src/routes/entries.rs +2 -2
@@ -121,7 +121,7 @@ pub async fn entry_new(
121121 q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m)))
122122 };
123123 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
124- let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
124+ let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
125125
126126 let timer_row = timers::get(&state.pool, ctx.user.id).await?;
127127 // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et
@@ -321,7 +321,7 @@ pub async fn entries_list(
321321 entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?;
322322
323323 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
324- let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
324+ let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
325325
326326 let entries = rows
327327 .into_iter()
@@ -121,7 +121,7 @@ pub async fn entry_new(
121 q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m)))121 q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m)))
122 };122 };
123 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;123 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
124- let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());124+ let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
125 125
126 let timer_row = timers::get(&state.pool, ctx.user.id).await?;126 let timer_row = timers::get(&state.pool, ctx.user.id).await?;
127 // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et127 // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et
@@ -321,7 +321,7 @@ pub async fn entries_list(
321 entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?;321 entries::list_first(&state.pool, ctx.user.id, ctx.team_id, page * PAGE_SIZE).await?;
322 322
323 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;323 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
324- let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());324+ let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
325 325
326 let entries = rows326 let entries = rows
327 .into_iter()327 .into_iter()
modified src/routes/mod.rs +4 -2
@@ -48,6 +48,7 @@ pub fn router(state: AppState) -> Router {
4848 post(settings::password_change),
4949 )
5050 .route("/parametres/langue", post(settings::lang_change))
51+ .route("/parametres/affichage", post(settings::theme_change))
5152 .route("/taches", get(todos::todos_page).post(todos::todo_create))
5253 .route("/taches/nouvelle", get(todos::todo_new))
5354 .route("/taches/{id}/modifier", get(todos::todo_edit))
@@ -170,7 +171,8 @@ pub async fn shell(
170171 lang_code: ctx.lang.code(),
171172 tr,
172173 team_name: ctx.team_name.clone(),
173- team_color: team_color(ctx.team_id),
174+ team_color: team_color(ctx.team_id, ctx.hc),
175+ hc: ctx.hc,
174176 tab,
175177 toast,
176178 invite_count,
@@ -193,7 +195,7 @@ pub async fn shell(
193195 .map(|t| TeamChoiceView {
194196 current: t.id == ctx.team_id,
195197 meta: tr.members_count(t.members),
196- color: team_color(t.id),
198+ color: team_color(t.id, ctx.hc),
197199 id: t.id,
198200 name: t.name,
199201 })
@@ -48,6 +48,7 @@ pub fn router(state: AppState) -> Router {
48 post(settings::password_change),48 post(settings::password_change),
49 )49 )
50 .route("/parametres/langue", post(settings::lang_change))50 .route("/parametres/langue", post(settings::lang_change))
51+ .route("/parametres/affichage", post(settings::theme_change))
51 .route("/taches", get(todos::todos_page).post(todos::todo_create))52 .route("/taches", get(todos::todos_page).post(todos::todo_create))
52 .route("/taches/nouvelle", get(todos::todo_new))53 .route("/taches/nouvelle", get(todos::todo_new))
53 .route("/taches/{id}/modifier", get(todos::todo_edit))54 .route("/taches/{id}/modifier", get(todos::todo_edit))
@@ -170,7 +171,8 @@ pub async fn shell(
170 lang_code: ctx.lang.code(),171 lang_code: ctx.lang.code(),
171 tr,172 tr,
172 team_name: ctx.team_name.clone(),173 team_name: ctx.team_name.clone(),
173- team_color: team_color(ctx.team_id),174+ team_color: team_color(ctx.team_id, ctx.hc),
175+ hc: ctx.hc,
174 tab,176 tab,
175 toast,177 toast,
176 invite_count,178 invite_count,
@@ -193,7 +195,7 @@ pub async fn shell(
193 .map(|t| TeamChoiceView {195 .map(|t| TeamChoiceView {
194 current: t.id == ctx.team_id,196 current: t.id == ctx.team_id,
195 meta: tr.members_count(t.members),197 meta: tr.members_count(t.members),
196- color: team_color(t.id),198+ color: team_color(t.id, ctx.hc),
197 id: t.id,199 id: t.id,
198 name: t.name,200 name: t.name,
199 })201 })
modified src/routes/settings.rs +49 -1
@@ -43,6 +43,8 @@ pub struct SettingsPage {
4343 pub invite_error: Option<String>,
4444 /// Sélecteur de langue (par utilisateur, en base).
4545 pub langs: Vec<LangView>,
46+ /// Sélecteur de thème (par utilisateur, en base — section Affichage).
47+ pub themes: Vec<ThemeView>,
4648 pub members_title: String,
4749 pub leave_label: String,
4850 pub sent: Vec<SentView>,
@@ -73,6 +75,7 @@ pub async fn settings_page(
7375 Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()),
7476 Some("quittee") => Some(tr.toast_team_left.to_string()),
7577 Some("langue") => Some(tr.toast_lang_updated.to_string()),
78+ Some("affichage") => Some(tr.toast_theme_updated.to_string()),
7679 _ => match q.err.as_deref() {
7780 Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),
7881 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),
@@ -132,11 +135,26 @@ pub async fn settings_page(
132135 current: l == ctx.lang,
133136 })
134137 .collect();
138+ let themes = vec![
139+ ThemeView {
140+ code: "organic",
141+ name: tr.theme_organic,
142+ desc: tr.theme_organic_desc,
143+ current: !ctx.hc,
144+ },
145+ ThemeView {
146+ code: "hc",
147+ name: tr.theme_hc,
148+ desc: tr.theme_hc_desc,
149+ current: ctx.hc,
150+ },
151+ ];
135152 Ok(HtmlTemplate(SettingsPage {
136153 csrf: ctx.user.csrf_token(&state),
137154 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,
138155 error,
139156 langs,
157+ themes,
140158 members_title: p1(tr.members_of_pat, &ctx.team_name),
141159 leave_label: p1(tr.leave_team_pat, &ctx.team_name),
142160 invites,
@@ -183,7 +201,7 @@ pub async fn categories_page(
183201 _ => None,
184202 };
185203 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;
186- let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>());
204+ let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
187205 let mut list = by_creation;
188206 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));
189207 let categories = list
@@ -216,12 +234,25 @@ pub struct LangView {
216234 pub current: bool,
217235 }
218236
237+pub struct ThemeView {
238+ pub code: &'static str,
239+ pub name: &'static str,
240+ pub desc: &'static str,
241+ pub current: bool,
242+}
243+
219244 #[derive(Deserialize)]
220245 pub struct LangForm {
221246 langue: String,
222247 csrf: String,
223248 }
224249
250+#[derive(Deserialize)]
251+pub struct ThemeForm {
252+ theme: String,
253+ csrf: String,
254+}
255+
225256 /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base
226257 /// (jamais par navigateur : le réglage suit le compte partout).
227258 pub async fn lang_change(
@@ -239,6 +270,23 @@ pub async fn lang_change(
239270 Ok(Redirect::to("/parametres?ok=langue"))
240271 }
241272
273+/// POST /parametres/affichage — thème PAR UTILISATEUR, enregistré en base
274+/// (comme la langue : le réglage suit le compte partout).
275+pub async fn theme_change(
276+ State(state): State<AppState>,
277+ ctx: ActiveTeam,
278+ Form(form): Form<ThemeForm>,
279+) -> Result<Redirect, AppError> {
280+ if !ctx.user.check_csrf(&state, &form.csrf) {
281+ return Err(AppError::Csrf);
282+ }
283+ if !matches!(form.theme.as_str(), "organic" | "hc") {
284+ return Err(AppError::BadRequest("bad theme".into()));
285+ }
286+ users::set_theme(&state.pool, ctx.user.id, &form.theme).await?;
287+ Ok(Redirect::to("/parametres?ok=affichage"))
288+}
289+
242290 fn is_unique_violation(e: &sqlx::Error) -> bool {
243291 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
244292 }
@@ -43,6 +43,8 @@ pub struct SettingsPage {
43 pub invite_error: Option<String>,43 pub invite_error: Option<String>,
44 /// Sélecteur de langue (par utilisateur, en base).44 /// Sélecteur de langue (par utilisateur, en base).
45 pub langs: Vec<LangView>,45 pub langs: Vec<LangView>,
46+ /// Sélecteur de thème (par utilisateur, en base — section Affichage).
47+ pub themes: Vec<ThemeView>,
46 pub members_title: String,48 pub members_title: String,
47 pub leave_label: String,49 pub leave_label: String,
48 pub sent: Vec<SentView>,50 pub sent: Vec<SentView>,
@@ -73,6 +75,7 @@ pub async fn settings_page(
73 Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()),75 Some("eq-renommee") => Some(tr.toast_team_renamed.to_string()),
74 Some("quittee") => Some(tr.toast_team_left.to_string()),76 Some("quittee") => Some(tr.toast_team_left.to_string()),
75 Some("langue") => Some(tr.toast_lang_updated.to_string()),77 Some("langue") => Some(tr.toast_lang_updated.to_string()),
78+ Some("affichage") => Some(tr.toast_theme_updated.to_string()),
76 _ => match q.err.as_deref() {79 _ => match q.err.as_deref() {
77 Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),80 Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),
78 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),81 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),
@@ -132,11 +135,26 @@ pub async fn settings_page(
132 current: l == ctx.lang,135 current: l == ctx.lang,
133 })136 })
134 .collect();137 .collect();
138+ let themes = vec![
139+ ThemeView {
140+ code: "organic",
141+ name: tr.theme_organic,
142+ desc: tr.theme_organic_desc,
143+ current: !ctx.hc,
144+ },
145+ ThemeView {
146+ code: "hc",
147+ name: tr.theme_hc,
148+ desc: tr.theme_hc_desc,
149+ current: ctx.hc,
150+ },
151+ ];
135 Ok(HtmlTemplate(SettingsPage {152 Ok(HtmlTemplate(SettingsPage {
136 csrf: ctx.user.csrf_token(&state),153 csrf: ctx.user.csrf_token(&state),
137 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,154 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,
138 error,155 error,
139 langs,156 langs,
157+ themes,
140 members_title: p1(tr.members_of_pat, &ctx.team_name),158 members_title: p1(tr.members_of_pat, &ctx.team_name),
141 leave_label: p1(tr.leave_team_pat, &ctx.team_name),159 leave_label: p1(tr.leave_team_pat, &ctx.team_name),
142 invites,160 invites,
@@ -183,7 +201,7 @@ pub async fn categories_page(
183 _ => None,201 _ => None,
184 };202 };
185 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;203 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;
186- let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>());204+ let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
187 let mut list = by_creation;205 let mut list = by_creation;
188 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));206 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));
189 let categories = list207 let categories = list
@@ -216,12 +234,25 @@ pub struct LangView {
216 pub current: bool,234 pub current: bool,
217 }235 }
218 236
237+pub struct ThemeView {
238+ pub code: &'static str,
239+ pub name: &'static str,
240+ pub desc: &'static str,
241+ pub current: bool,
242+}
243+
219 #[derive(Deserialize)]244 #[derive(Deserialize)]
220 pub struct LangForm {245 pub struct LangForm {
221 langue: String,246 langue: String,
222 csrf: String,247 csrf: String,
223 }248 }
224 249
250+#[derive(Deserialize)]
251+pub struct ThemeForm {
252+ theme: String,
253+ csrf: String,
254+}
255+
225 /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base256 /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base
226 /// (jamais par navigateur : le réglage suit le compte partout).257 /// (jamais par navigateur : le réglage suit le compte partout).
227 pub async fn lang_change(258 pub async fn lang_change(
@@ -239,6 +270,23 @@ pub async fn lang_change(
239 Ok(Redirect::to("/parametres?ok=langue"))270 Ok(Redirect::to("/parametres?ok=langue"))
240 }271 }
241 272
273+/// POST /parametres/affichage — thème PAR UTILISATEUR, enregistré en base
274+/// (comme la langue : le réglage suit le compte partout).
275+pub async fn theme_change(
276+ State(state): State<AppState>,
277+ ctx: ActiveTeam,
278+ Form(form): Form<ThemeForm>,
279+) -> Result<Redirect, AppError> {
280+ if !ctx.user.check_csrf(&state, &form.csrf) {
281+ return Err(AppError::Csrf);
282+ }
283+ if !matches!(form.theme.as_str(), "organic" | "hc") {
284+ return Err(AppError::BadRequest("bad theme".into()));
285+ }
286+ users::set_theme(&state.pool, ctx.user.id, &form.theme).await?;
287+ Ok(Redirect::to("/parametres?ok=affichage"))
288+}
289+
242 fn is_unique_violation(e: &sqlx::Error) -> bool {290 fn is_unique_violation(e: &sqlx::Error) -> bool {
243 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())291 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
244 }292 }
modified src/routes/stats.rs +1 -1
@@ -61,7 +61,7 @@ pub async fn stats_page(
6161 let (year_from, year_to) = year.bounds();
6262
6363 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
64- let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
64+ let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
6565
6666 let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?;
6767 let month_rows =
@@ -61,7 +61,7 @@ pub async fn stats_page(
61 let (year_from, year_to) = year.bounds();61 let (year_from, year_to) = year.bounds();
62 62
63 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;63 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
64- let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());64+ let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
65 65
66 let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?;66 let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?;
67 let month_rows =67 let month_rows =
modified src/routes/todos.rs +3 -3
@@ -120,7 +120,7 @@ pub async fn todos_page(
120120 by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username));
121121 }
122122 let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?;
123- let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>());
123+ let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc);
124124
125125 let card = |t: &TodoRow| {
126126 let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]);
@@ -275,7 +275,7 @@ async fn form_page(
275275 err: Option<&str>,
276276 ) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
277277 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
278- let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
278+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc);
279279 let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today);
280280 let selected_cat = todo.as_ref().map(|t| t.category_id);
281281 let assigned = assigned.unwrap_or_default();
@@ -509,7 +509,7 @@ pub async fn todo_done_page(
509509 today,
510510 );
511511 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
512- let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
512+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc);
513513
514514 let minutes = q.minutes.unwrap_or(30).max(1);
515515 let date = q.date.unwrap_or(today).min(today);
@@ -120,7 +120,7 @@ pub async fn todos_page(
120 by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username));120 by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username));
121 }121 }
122 let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?;122 let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?;
123- let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>());123+ let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc);
124 124
125 let card = |t: &TodoRow| {125 let card = |t: &TodoRow| {
126 let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]);126 let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]);
@@ -275,7 +275,7 @@ async fn form_page(
275 err: Option<&str>,275 err: Option<&str>,
276 ) -> Result<HtmlTemplate<TodoFormPage>, AppError> {276 ) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
277 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;277 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
278- let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());278+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc);
279 let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today);279 let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today);
280 let selected_cat = todo.as_ref().map(|t| t.category_id);280 let selected_cat = todo.as_ref().map(|t| t.category_id);
281 let assigned = assigned.unwrap_or_default();281 let assigned = assigned.unwrap_or_default();
@@ -509,7 +509,7 @@ pub async fn todo_done_page(
509 today,509 today,
510 );510 );
511 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;511 let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
512- let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());512+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>(), ctx.hc);
513 513
514 let minutes = q.minutes.unwrap_or(30).max(1);514 let minutes = q.minutes.unwrap_or(30).max(1);
515 let date = q.date.unwrap_or(today).min(today);515 let date = q.date.unwrap_or(today).min(today);
modified src/team.rs +6 -1
@@ -40,6 +40,8 @@ pub struct ActiveTeam {
4040 pub username: String,
4141 /// Langue de l'interface — par utilisateur, stockée en base.
4242 pub lang: Lang,
43+ /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base.
44+ pub hc: bool,
4345 pub team_id: Uuid,
4446 pub team_name: String,
4547 }
@@ -78,10 +80,11 @@ impl FromRequestParts<AppState> for ActiveTeam {
7880 state: &AppState,
7981 ) -> Result<Self, TeamRejection> {
8082 let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;
81- let (username, lang_code) = users::profile(&state.pool, user.id)
83+ let (username, lang_code, theme) = users::profile(&state.pool, user.id)
8284 .await?
8385 .unwrap_or_default();
8486 let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr);
87+ let hc = theme == "hc";
8588 let jar = CookieJar::from_headers(&parts.headers);
8689 let wanted = jar
8790 .get(TEAM_COOKIE)
@@ -93,6 +96,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
9396 user,
9497 username,
9598 lang,
99+ hc,
96100 team_id: id,
97101 team_name: name,
98102 });
@@ -103,6 +107,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
103107 user,
104108 username,
105109 lang,
110+ hc,
106111 team_id: id,
107112 team_name: name,
108113 }),
@@ -40,6 +40,8 @@ pub struct ActiveTeam {
40 pub username: String,40 pub username: String,
41 /// Langue de l'interface — par utilisateur, stockée en base.41 /// Langue de l'interface — par utilisateur, stockée en base.
42 pub lang: Lang,42 pub lang: Lang,
43+ /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base.
44+ pub hc: bool,
43 pub team_id: Uuid,45 pub team_id: Uuid,
44 pub team_name: String,46 pub team_name: String,
45 }47 }
@@ -78,10 +80,11 @@ impl FromRequestParts<AppState> for ActiveTeam {
78 state: &AppState,80 state: &AppState,
79 ) -> Result<Self, TeamRejection> {81 ) -> Result<Self, TeamRejection> {
80 let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;82 let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;
81- let (username, lang_code) = users::profile(&state.pool, user.id)83+ let (username, lang_code, theme) = users::profile(&state.pool, user.id)
82 .await?84 .await?
83 .unwrap_or_default();85 .unwrap_or_default();
84 let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr);86 let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr);
87+ let hc = theme == "hc";
85 let jar = CookieJar::from_headers(&parts.headers);88 let jar = CookieJar::from_headers(&parts.headers);
86 let wanted = jar89 let wanted = jar
87 .get(TEAM_COOKIE)90 .get(TEAM_COOKIE)
@@ -93,6 +96,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
93 user,96 user,
94 username,97 username,
95 lang,98 lang,
99+ hc,
96 team_id: id,100 team_id: id,
97 team_name: name,101 team_name: name,
98 });102 });
@@ -103,6 +107,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
103 user,107 user,
104 username,108 username,
105 lang,109 lang,
110+ hc,
106 team_id: id,111 team_id: id,
107 team_name: name,112 team_name: name,
108 }),113 }),
modified templates/app.html +2 -0
@@ -1,6 +1,8 @@
11 {% extends "base.html" %}
22 {% block title %}{{ shell.title }} · Ex Ego{% endblock %}
33 {% block lang %}{{ shell.lang_code }}{% endblock %}
4+{% block themeattr %}{% if shell.hc %} data-theme="hc"{% endif %}{% endblock %}
5+{% block themecolor %}{% if shell.hc %}#000000{% else %}#f5ead8{% endif %}{% endblock %}
46 {% block body %}
57 <header class="app-header">
68 <div class="app-topline">
@@ -1,6 +1,8 @@
1 {% extends "base.html" %}1 {% extends "base.html" %}
2 {% block title %}{{ shell.title }} · Ex Ego{% endblock %}2 {% block title %}{{ shell.title }} · Ex Ego{% endblock %}
3 {% block lang %}{{ shell.lang_code }}{% endblock %}3 {% block lang %}{{ shell.lang_code }}{% endblock %}
4+{% block themeattr %}{% if shell.hc %} data-theme="hc"{% endif %}{% endblock %}
5+{% block themecolor %}{% if shell.hc %}#000000{% else %}#f5ead8{% endif %}{% endblock %}
4 {% block body %}6 {% block body %}
5 <header class="app-header">7 <header class="app-header">
6 <div class="app-topline">8 <div class="app-topline">
modified templates/base.html +2 -2
@@ -1,9 +1,9 @@
11 <!doctype html>
2-<html lang="{% block lang %}fr{% endblock %}">
2+<html lang="{% block lang %}fr{% endblock %}"{% block themeattr %}{% endblock %}>
33 <head>
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6-<meta name="theme-color" content="#f5ead8">
6+<meta name="theme-color" content="{% block themecolor %}#f5ead8{% endblock %}">
77 <meta name="apple-mobile-web-app-title" content="Ex Ego">
88 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
99 <link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}">
@@ -1,9 +1,9 @@
1 <!doctype html>1 <!doctype html>
2-<html lang="{% block lang %}fr{% endblock %}">2+<html lang="{% block lang %}fr{% endblock %}"{% block themeattr %}{% endblock %}>
3 <head>3 <head>
4 <meta charset="utf-8">4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6-<meta name="theme-color" content="#f5ead8">6+<meta name="theme-color" content="{% block themecolor %}#f5ead8{% endblock %}">
7 <meta name="apple-mobile-web-app-title" content="Ex Ego">7 <meta name="apple-mobile-web-app-title" content="Ex Ego">
8 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">8 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9 <link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}">9 <link rel="manifest" href="{{ crate::routes::statics::manifest_href() }}">
modified templates/settings.html +19 -0
@@ -113,6 +113,25 @@
113113 {% endfor %}
114114 </div>
115115
116+<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.display }}</h2>
117+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.display_hint }}</p>
118+<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-2)">
119+ {% for th in themes %}
120+ <form method="post" action="/parametres/affichage" style="margin:0">
121+ <input type="hidden" name="csrf" value="{{ csrf }}">
122+ <input type="hidden" name="theme" value="{{ th.code }}">
123+ <button type="submit" class="theme-choice{% if th.current %} current{% endif %}" {% if th.current %}aria-current="true"{% endif %}>
124+ <span class="theme-swatch swatch-{{ th.code }}"></span>
125+ <span style="flex:1;text-align:left">
126+ <span style="display:block;font-size:16px">{{ th.name }}</span>
127+ <span style="display:block;font-size:12px;opacity:.75">{{ th.desc }}</span>
128+ </span>
129+ {% if th.current %}<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %}
130+ </button>
131+ </form>
132+ {% endfor %}
133+</div>
134+
116135 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
117136 <form method="post" action="/parametres/mot-de-passe">
118137 <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -113,6 +113,25 @@
113 {% endfor %}113 {% endfor %}
114 </div>114 </div>
115 115
116+<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.display }}</h2>
117+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.display_hint }}</p>
118+<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-2)">
119+ {% for th in themes %}
120+ <form method="post" action="/parametres/affichage" style="margin:0">
121+ <input type="hidden" name="csrf" value="{{ csrf }}">
122+ <input type="hidden" name="theme" value="{{ th.code }}">
123+ <button type="submit" class="theme-choice{% if th.current %} current{% endif %}" {% if th.current %}aria-current="true"{% endif %}>
124+ <span class="theme-swatch swatch-{{ th.code }}"></span>
125+ <span style="flex:1;text-align:left">
126+ <span style="display:block;font-size:16px">{{ th.name }}</span>
127+ <span style="display:block;font-size:12px;opacity:.75">{{ th.desc }}</span>
128+ </span>
129+ {% if th.current %}<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %}
130+ </button>
131+ </form>
132+ {% endfor %}
133+</div>
134+
116 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>135 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
117 <form method="post" action="/parametres/mot-de-passe">136 <form method="post" action="/parametres/mot-de-passe">
118 <input type="hidden" name="csrf" value="{{ csrf }}">137 <input type="hidden" name="csrf" value="{{ csrf }}">