From b71272a40161dcd941e8729ac3dbba5591c213e5 Mon Sep 17 00:00:00 2001 From: Romain Hild Date: Mon, 8 Jun 2026 23:19:56 +0200 Subject: [PATCH 1/2] feat: make shopping list and pantry nav links configurable via settings Shopping List and Pantry nav links were removed from the nav bar and replaced with per-user toggles in Preferences. Both features are shown by default; users opt out to hide them. Settings persist via cookies refreshed on every request. Includes translations for all 7 supported languages and E2E test coverage. --- .../plans/2026-06-08-feature-flags-nav.md | 1329 +++++++++++++++++ .../2026-06-08-feature-flags-nav-design.md | 170 +++ locales/de-DE/preferences.ftl | 4 + locales/en-US/preferences.ftl | 4 + locales/es-ES/preferences.ftl | 4 + locales/eu-ES/preferences.ftl | 4 + locales/fr-FR/preferences.ftl | 4 + locales/nl-NL/preferences.ftl | 4 + locales/sv-SE/preferences.ftl | 4 + src/build/renderer.rs | 4 + src/server/builders.rs | 10 + src/server/language.rs | 134 +- src/server/mod.rs | 7 + src/server/templates.rs | 11 + src/server/ui.rs | 32 +- templates/base.html | 62 +- templates/preferences.html | 25 + tests/e2e/navigation.spec.ts | 63 + tests/e2e/preferences.spec.ts | 53 + 19 files changed, 1910 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-feature-flags-nav.md create mode 100644 docs/superpowers/specs/2026-06-08-feature-flags-nav-design.md diff --git a/docs/superpowers/plans/2026-06-08-feature-flags-nav.md b/docs/superpowers/plans/2026-06-08-feature-flags-nav.md new file mode 100644 index 00000000..b3016d00 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-feature-flags-nav.md @@ -0,0 +1,1329 @@ +# Feature Flags: Configurable Nav Bar — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users enable/disable Shopping List and Pantry nav links from the Preferences page; both features are shown by default and the setting persists via cookies refreshed on every request. + +**Architecture:** A `FeatureFlags` struct lives in `src/server/language.rs` alongside the existing language cookie logic. An Axum middleware reads feature flag cookies, injects `FeatureFlags` as a request extension, and refreshes the cookie expiry on every response. Every template struct gains a `features: FeatureFlags` field; `base.html` renders nav pills conditionally; `preferences.html` shows toggle buttons. + +**Tech Stack:** Rust/Axum, Askama templates, Tailwind CSS, Fluent (FTL) i18n, Playwright (E2E tests) + +--- + +## File Map + +| File | Change | +|------|--------| +| `src/server/language.rs` | Add `FeatureFlags`, `parse_feature_flags`, `features_middleware` | +| `src/server/mod.rs` | Register `features_middleware` with `from_fn_with_state` | +| `src/server/templates.rs` | Add `features: FeatureFlags` to all 9 template structs | +| `src/server/builders.rs` | Add `features` to `RecipesBuildInput` and `RecipeBuildInput`; thread through | +| `src/server/ui.rs` | Extract `Extension(features)` in every handler; update `error_page` | +| `src/build/renderer.rs` | Pass `FeatureFlags::default()` to builder inputs (static mode) | +| `templates/base.html` | Conditional nav pills (desktop + mobile dropdown); dark mode CSS | +| `templates/preferences.html` | Features section card + `toggleFeature` JS | +| `locales/en-US/preferences.ftl` | Add `pref-features`, `pref-features-desc` | +| `locales/de-DE/preferences.ftl` | Same | +| `locales/nl-NL/preferences.ftl` | Same | +| `locales/fr-FR/preferences.ftl` | Same | +| `locales/es-ES/preferences.ftl` | Same | +| `locales/eu-ES/preferences.ftl` | Same | +| `locales/sv-SE/preferences.ftl` | Same | +| `tests/e2e/navigation.spec.ts` | Tests for feature-flag nav visibility | +| `tests/e2e/preferences.spec.ts` | Tests for feature toggle in preferences UI | + +--- + +## Task 1: `FeatureFlags` struct and cookie parsing + +**Files:** +- Modify: `src/server/language.rs` + +- [ ] **Step 1.1 — Write the failing unit tests** + +Add at the bottom of `src/server/language.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn make_cookie_headers(cookies: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(header::COOKIE, cookies.parse().unwrap()); + h + } + + #[test] + fn test_feature_flags_default_true_when_no_cookies() { + let flags = parse_feature_flags(&HeaderMap::new()); + assert!(flags.show_shopping_list); + assert!(flags.show_pantry); + } + + #[test] + fn test_feature_flags_disabled_by_zero() { + let flags = parse_feature_flags(&make_cookie_headers( + "show_shopping_list=0; show_pantry=0", + )); + assert!(!flags.show_shopping_list); + assert!(!flags.show_pantry); + } + + #[test] + fn test_feature_flags_enabled_by_one() { + let flags = parse_feature_flags(&make_cookie_headers( + "show_shopping_list=1; show_pantry=1", + )); + assert!(flags.show_shopping_list); + assert!(flags.show_pantry); + } + + #[test] + fn test_feature_flags_partial_override() { + let flags = + parse_feature_flags(&make_cookie_headers("show_shopping_list=0")); + assert!(!flags.show_shopping_list); + assert!(flags.show_pantry); // absent → default true + } + + #[test] + fn test_feature_flags_unknown_value_treated_as_enabled() { + // Anything that isn't "0" is truthy + let flags = parse_feature_flags(&make_cookie_headers( + "show_shopping_list=yes", + )); + assert!(flags.show_shopping_list); + } +} +``` + +- [ ] **Step 1.2 — Run tests to confirm they fail** + +```bash +cd /Users/romain/Projects/cooklang/cookcli +cargo test test_feature_flags 2>&1 | head -30 +``` + +Expected: compile errors — `parse_feature_flags` and `FeatureFlags` are not defined yet. + +- [ ] **Step 1.3 — Implement `FeatureFlags` and `parse_feature_flags`** + +Add the following to `src/server/language.rs` after the `SUPPORTED_LANGUAGES` constant (around line 20): + +```rust +/// Per-request feature visibility flags, read from cookies. +#[derive(Clone, Debug)] +pub struct FeatureFlags { + pub show_shopping_list: bool, + pub show_pantry: bool, +} + +impl Default for FeatureFlags { + fn default() -> Self { + Self { + show_shopping_list: true, + show_pantry: true, + } + } +} + +/// Parse feature flag cookies from request headers. +/// Absent cookie → feature enabled (default true). +/// Cookie value "0" → disabled. Any other value → enabled. +pub fn parse_feature_flags(headers: &HeaderMap) -> FeatureFlags { + let mut flags = FeatureFlags::default(); + + if let Some(cookie_header) = headers.get(header::COOKIE) { + if let Ok(cookie_str) = cookie_header.to_str() { + for cookie in cookie_str.split(';') { + let parts: Vec<&str> = cookie.trim().splitn(2, '=').collect(); + if parts.len() == 2 { + match parts[0] { + "show_shopping_list" => { + flags.show_shopping_list = parts[1] != "0" + } + "show_pantry" => flags.show_pantry = parts[1] != "0", + _ => {} + } + } + } + } + } + + flags +} +``` + +- [ ] **Step 1.4 — Run tests to confirm they pass** + +```bash +cargo test test_feature_flags 2>&1 +``` + +Expected: 5 tests pass. + +- [ ] **Step 1.5 — Commit** + +```bash +git add src/server/language.rs +git commit -m "feat: add FeatureFlags struct and cookie parsing for nav feature toggles" +``` + +--- + +## Task 2: Features middleware + +**Files:** +- Modify: `src/server/language.rs` +- Modify: `src/server/mod.rs` + +- [ ] **Step 2.1 — Add `State` to imports in `language.rs`** + +In `src/server/language.rs`, change the existing `use axum::{...}` block to include `State`: + +```rust +use axum::{ + extract::{Request, State}, + http::{header, HeaderMap}, + middleware::Next, + response::Response, +}; +``` + +- [ ] **Step 2.2 — Implement `features_middleware` in `language.rs`** + +Add after the `language_middleware` function: + +```rust +/// Middleware that reads feature flag cookies, injects them as a request +/// extension, and refreshes the cookie expiry on every response. +/// Takes the URL prefix as state so Set-Cookie headers use the correct path. +pub async fn features_middleware( + State(url_prefix): State, + mut req: Request, + next: Next, +) -> Response { + let features = parse_feature_flags(req.headers()); + req.extensions_mut().insert(features.clone()); + let mut response = next.run(req).await; + + let max_age = 365 * 24 * 60 * 60_u32; + let cookie_path = if url_prefix.is_empty() { + "/".to_string() + } else { + url_prefix.clone() + }; + + for (name, val) in [ + ( + "show_shopping_list", + if features.show_shopping_list { "1" } else { "0" }, + ), + ("show_pantry", if features.show_pantry { "1" } else { "0" }), + ] { + let cookie = format!( + "{name}={val}; path={cookie_path}; max-age={max_age}; SameSite=Lax" + ); + if let Ok(header_val) = cookie.parse() { + response.headers_mut().append(header::SET_COOKIE, header_val); + } + } + + response +} +``` + +- [ ] **Step 2.3 — Register the middleware in `mod.rs`** + +In `src/server/mod.rs`, in the `run` function, extract `url_prefix` before `with_state` consumes `state`, then register the middleware. Find the block starting with `#[cfg(feature = "sync")] let state_for_shutdown = state.clone();` and add the prefix capture right before it: + +```rust + // Capture url_prefix before state is consumed by with_state. + let url_prefix_for_features = state.url_prefix.clone(); + + #[cfg(feature = "sync")] + let state_for_shutdown = state.clone(); + + let app = app + .with_state(state) + .layer(DefaultBodyLimit::max(MAX_BODY_SIZE)) + .layer(axum::middleware::from_fn_with_state( + url_prefix_for_features, + language::features_middleware, + )) + .layer(axum::middleware::from_fn(language::language_middleware)) + .layer( + CorsLayer::new() + .allow_origin("*".parse::().unwrap()) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]), + ); +``` + +- [ ] **Step 2.4 — Build to confirm it compiles** + +```bash +cargo build -p cookcli 2>&1 | grep -E "^error" +``` + +Expected: no errors. + +- [ ] **Step 2.5 — Commit** + +```bash +git add src/server/language.rs src/server/mod.rs +git commit -m "feat: add features_middleware that injects FeatureFlags and refreshes cookies" +``` + +--- + +## Task 3: Add `FeatureFlags` to template structs, builders, and handlers + +This task must be done in one step because Askama validates template fields at compile time. All structs must have `features` before templates can reference it. + +**Files:** +- Modify: `src/server/templates.rs` +- Modify: `src/server/builders.rs` +- Modify: `src/server/ui.rs` +- Modify: `src/build/renderer.rs` + +- [ ] **Step 3.1 — Add import and `features` field to all template structs in `templates.rs`** + +At the top of `src/server/templates.rs`, add the import after the existing imports: + +```rust +use crate::server::language::FeatureFlags; +``` + +Then add `pub features: FeatureFlags,` to each template struct. The complete list of structs to update and their new fields (add after the last existing field in each): + +```rust +// ErrorTemplate +pub struct ErrorTemplate { + pub active: String, + pub error_message: String, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// RecipesTemplate +pub struct RecipesTemplate { + pub active: String, + pub current_name: String, + pub breadcrumbs: Vec, + pub items: Vec, + pub todays_menu: Option, + pub new_recipe_url: String, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// RecipeTemplate +pub struct RecipeTemplate { + pub active: String, + pub recipe: RecipeData, + pub recipe_path: String, + pub breadcrumbs: Vec, + pub scale: f64, + pub tags: Vec, + pub ingredients: Vec, + pub cookware: Vec, + pub sections: Vec, + pub image_path: Option, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// MenuTemplate +pub struct MenuTemplate { + pub active: String, + pub name: String, + pub recipe_path: String, + pub breadcrumbs: Vec, + pub scale: f64, + pub metadata: Option, + pub sections: Vec, + pub image_path: Option, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// ShoppingListTemplate +pub struct ShoppingListTemplate { + pub active: String, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// PreferencesTemplate +pub struct PreferencesTemplate { + pub active: String, + pub aisle_path: String, + pub pantry_path: String, + pub base_path: String, + pub version: String, + pub tr: Tr, + pub sync_enabled: bool, + pub sync_logged_in: bool, + pub sync_email: Option, + pub sync_syncing: bool, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// PantryTemplate +pub struct PantryTemplate { + pub active: String, + pub configured: bool, + pub sections: Vec, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// EditTemplate +pub struct EditTemplate { + pub active: String, + pub recipe_name: String, + pub recipe_path: String, + pub content: String, + pub base_path: String, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} + +// NewTemplate +pub struct NewTemplate { + pub active: String, + pub tr: Tr, + pub error: Option, + pub filename: Option, + pub prefix: String, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} +``` + +- [ ] **Step 3.2 — Add `features` to builder input structs in `builders.rs`** + +In `src/server/builders.rs`, add to imports at top: + +```rust +use crate::server::language::FeatureFlags; +``` + +Update `RecipesBuildInput`: + +```rust +pub struct RecipesBuildInput<'a> { + pub base_path: &'a Utf8Path, + pub url_prefix: &'a str, + pub sub_path: Option<&'a str>, + pub lang: LanguageIdentifier, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} +``` + +Update `RecipeBuildInput`: + +```rust +pub struct RecipeBuildInput<'a> { + pub base_path: &'a Utf8Path, + pub url_prefix: &'a str, + pub recipe_path: &'a str, + pub aisle_path: Option<&'a Utf8PathBuf>, + pub scale: f64, + pub lang: LanguageIdentifier, + pub static_mode: bool, + pub features: FeatureFlags, // ADD +} +``` + +In `build_recipes_template`, destructure the new field and pass it to the template: + +```rust +pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result { + let RecipesBuildInput { + base_path, + url_prefix, + sub_path, + lang, + static_mode, + features, // ADD + } = input; + // ... existing body unchanged ... + Ok(RecipesTemplate { + active: "recipes".to_string(), + current_name, + breadcrumbs, + items, + todays_menu, + new_recipe_url, + tr: Tr::new(lang), + prefix: url_prefix.to_string(), + static_mode, + features, // ADD + }) +} +``` + +In `build_recipe_template`, destructure and thread through. In the `RecipeBuildInput` destructuring block: + +```rust + let RecipeBuildInput { + base_path, + url_prefix, + recipe_path, + aisle_path, + scale, + lang, + static_mode, + features, // ADD + } = input; +``` + +Pass to `RecipeTemplate` at the bottom of `build_recipe_template`: + +```rust + let template = RecipeTemplate { + active: "recipes".to_string(), + recipe: RecipeData { name: recipe_name, metadata }, + recipe_path: recipe_path.to_string(), + breadcrumbs, + scale, + tags, + ingredients, + cookware, + sections, + image_path, + tr: Tr::new(lang), + prefix: url_prefix.to_string(), + static_mode, + features, // ADD + }; +``` + +Pass to `build_menu_template_inner` (which builds `MenuTemplate`). Update the call in `build_recipe_template`: + +```rust + let template = build_menu_template_inner( + recipe_path.to_string(), + scale, + entry, + base_path, + url_prefix, + lang, + static_mode, + features, // ADD + )?; +``` + +Update `build_menu_template_inner` signature and constructor: + +```rust +fn build_menu_template_inner( + path: String, + scale: f64, + entry: cooklang_find::RecipeEntry, + base_path: &Utf8Path, + url_prefix: &str, + lang: LanguageIdentifier, + static_mode: bool, + features: FeatureFlags, // ADD +) -> Result { + // ... existing body unchanged ... + Ok(MenuTemplate { + active: "recipes".to_string(), + name: menu_name, + recipe_path: path, + breadcrumbs, + scale, + metadata, + sections, + image_path, + tr: Tr::new(lang), + prefix: url_prefix.to_string(), + static_mode, + features, // ADD + }) +} +``` + +- [ ] **Step 3.3 — Update `renderer.rs` to pass `FeatureFlags::default()`** + +In `src/build/renderer.rs`, add import at top: + +```rust +use crate::server::language::FeatureFlags; +``` + +In `render_index`, update the `RecipesBuildInput`: + +```rust + let template = build_recipes_template(RecipesBuildInput { + base_path: source, + url_prefix: &prefix, + sub_path: None, + lang: lang.clone(), + static_mode: true, + features: FeatureFlags::default(), // ADD + })?; +``` + +In `render_directory`, same change: + +```rust + let template = build_recipes_template(RecipesBuildInput { + base_path: source, + url_prefix: &prefix, + sub_path: Some(sub_path), + lang: lang.clone(), + static_mode: true, + features: FeatureFlags::default(), // ADD + })?; +``` + +In `render_recipe`, update `RecipeBuildInput`: + +```rust + let kind = build_recipe_template(RecipeBuildInput { + base_path: source, + url_prefix: &prefix, + recipe_path: trimmed, + aisle_path, + scale: 1.0, + lang: lang.clone(), + static_mode: true, + features: FeatureFlags::default(), // ADD + })?; +``` + +- [ ] **Step 3.4 — Update all handlers in `ui.rs`** + +Add import at the top of `src/server/ui.rs`: + +```rust +use crate::server::language::FeatureFlags; +``` + +Update `error_page` helper to accept and pass features: + +```rust +fn error_page( + lang: LanguageIdentifier, + prefix: &str, + msg: impl std::fmt::Display, + features: FeatureFlags, +) -> axum::response::Response { + let template = ErrorTemplate { + active: String::new(), + error_message: msg.to_string(), + tr: Tr::new(lang), + prefix: prefix.to_string(), + static_mode: false, + features, + }; + template.into_response() +} +``` + +Update `recipes_page`: + +```rust +async fn recipes_page( + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> axum::response::Response { + recipes_handler(state, None, lang, features).await +} +``` + +Update `recipes_directory`: + +```rust +async fn recipes_directory( + Path(path): Path, + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> axum::response::Response { + recipes_handler(state, Some(path), lang, features).await +} +``` + +Update `recipes_handler` to accept and thread features: + +```rust +async fn recipes_handler( + state: Arc, + path: Option, + lang: LanguageIdentifier, + features: FeatureFlags, +) -> axum::response::Response { + let input = crate::server::builders::RecipesBuildInput { + base_path: &state.base_path, + url_prefix: &state.url_prefix, + sub_path: path.as_deref(), + lang: lang.clone(), + static_mode: false, + features: features.clone(), + }; + match crate::server::builders::build_recipes_template(input) { + Ok(template) => template.into_response(), + Err(e) => { + tracing::error!("Failed to build recipes template: {:?}", e); + error_page(lang, &state.url_prefix, &e, features) + } + } +} +``` + +Update `recipe_page`: + +```rust +async fn recipe_page( + Path(path): Path, + Query(query): Query, + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> axum::response::Response { + let scale = query.scale.unwrap_or(1.0); + + let input = crate::server::builders::RecipeBuildInput { + base_path: &state.base_path, + url_prefix: &state.url_prefix, + recipe_path: &path, + aisle_path: state.aisle_path.as_ref(), + scale, + lang: lang.clone(), + static_mode: false, + features: features.clone(), + }; + + match crate::server::builders::build_recipe_template(input) { + Ok(crate::server::builders::RecipeBuildOutput::Recipe(template)) => { + template.into_response() + } + Ok(crate::server::builders::RecipeBuildOutput::Menu(template)) => template.into_response(), + Err(e) => { + tracing::error!("Failed to build recipe template: {:?}", e); + error_page(lang, &state.url_prefix, &e, features) + } + } +} +``` + +Update `edit_page`: + +```rust +async fn edit_page( + Path(path): Path, + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> axum::response::Response { + // ... existing validation/reading logic unchanged ... + let template = crate::server::templates::EditTemplate { + active: "recipes".to_string(), + recipe_name, + recipe_path: path, + content, + base_path: state.base_path.to_string(), + tr: crate::server::templates::Tr::new(lang), + prefix: state.url_prefix.clone(), + static_mode: false, + features, + }; + template.into_response() +} +``` + +(Keep all the existing path validation and file reading logic unchanged; just add `Extension(features): Extension` to the signature and `features,` to the template constructor.) + +Update `new_page`: + +```rust +async fn new_page( + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, + Query(query): Query, +) -> impl askama_axum::IntoResponse { + crate::server::templates::NewTemplate { + active: "recipes".to_string(), + tr: Tr::new(lang), + error: query.error, + filename: query.filename, + prefix: state.url_prefix.clone(), + static_mode: false, + features, + } +} +``` + +Update `shopping_list_page`: + +```rust +async fn shopping_list_page( + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> impl askama_axum::IntoResponse { + ShoppingListTemplate { + active: "shopping".to_string(), + tr: Tr::new(lang), + prefix: state.url_prefix.clone(), + static_mode: false, + features, + } +} +``` + +Update `pantry_page`: + +```rust +async fn pantry_page( + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> Result { + // ... existing pantry loading logic unchanged ... + Ok(PantryTemplate { + active: "pantry".to_string(), + configured: pantry_path.is_some(), + sections, + tr: Tr::new(lang), + prefix: state.url_prefix.clone(), + static_mode: false, + features, + }) +} +``` + +Update `preferences_page`: + +```rust +async fn preferences_page( + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, +) -> impl askama_axum::IntoResponse { + #[cfg(feature = "sync")] + let (sync_logged_in, sync_email, sync_syncing) = state.sync_status().await; + #[cfg(not(feature = "sync"))] + let (sync_logged_in, sync_email, sync_syncing) = (false, None, false); + + PreferencesTemplate { + active: "preferences".to_string(), + aisle_path: state + .aisle_path + .as_ref() + .map(|p| p.to_string()) + .unwrap_or_else(|| "Not configured".to_string()), + pantry_path: state + .pantry_path + .as_ref() + .map(|p| p.to_string()) + .unwrap_or_else(|| "Not configured".to_string()), + base_path: state.base_path.to_string(), + version: format!("{} - in food we trust", env!("CARGO_PKG_VERSION")), + tr: Tr::new(lang), + sync_enabled: cfg!(feature = "sync"), + sync_logged_in, + sync_email, + sync_syncing, + prefix: state.url_prefix.clone(), + static_mode: false, + features, + } +} +``` + +- [ ] **Step 3.5 — Build to confirm it compiles cleanly** + +```bash +cargo build -p cookcli 2>&1 | grep -E "^error" +``` + +Expected: no errors. If Askama reports a missing `features` field for a template, check that all 9 structs were updated. + +- [ ] **Step 3.6 — Commit** + +```bash +git add src/server/templates.rs src/server/builders.rs src/server/ui.rs src/build/renderer.rs +git commit -m "feat: thread FeatureFlags through all template structs, builders, and handlers" +``` + +--- + +## Task 4: Conditional nav links in `base.html` + +**Files:** +- Modify: `templates/base.html` + +- [ ] **Step 4.1 — Add dark mode CSS for `.nav-pill`** + +In `templates/base.html`, inside the existing `