Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,329 changes: 1,329 additions & 0 deletions docs/superpowers/plans/2026-06-08-feature-flags-nav.md

Large diffs are not rendered by default.

170 changes: 170 additions & 0 deletions docs/superpowers/specs/2026-06-08-feature-flags-nav-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Feature Flags: Configurable Nav Bar

**Date:** 2026-06-08

## Problem

Shopping List and Pantry were removed from the nav bar in a previous commit. Users who want those features back have no way to re-enable them. Recipes is always the default/mandatory page.

## Goal

Let users choose which features appear in the nav bar via the Preferences page. Shopping List and Pantry are shown by default (opt-out). Recipes is always the home page and cannot be hidden. If only Recipes is enabled, no nav links are shown at all (the bar still shows search, preferences, and theme toggle).

## Approach: Cookie-based, server-side rendering

Feature flags are stored as cookies, read in middleware, and injected as an Axum extension — exactly mirroring how the `lang` cookie and `LanguageIdentifier` extension work today. Askama templates render conditionally; no JS is needed for the nav itself.

---

## Data & Storage

### `FeatureFlags` struct

New struct in `src/server/language.rs` (alongside the existing language helpers):

```rust
#[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 }
}
}
```

### Cookies

| Cookie name | Values | Absent means |
|----------------------|---------|--------------|
| `show_shopping_list` | `1`/`0` | enabled (true) |
| `show_pantry` | `1`/`0` | enabled (true) |

Set with `path=<prefix>/; max-age=31536000; SameSite=Lax` (1-year expiry).

The middleware refreshes both cookies on every response, resetting their expiry to 1 year from the current request. This means preferences persist indefinitely as long as the user visits occasionally — they only expire if the user hasn't visited for a full year.

### Middleware

`features_middleware` in `src/server/language.rs` (or inline into the existing `language_middleware`) parses the two cookies, calls `req.extensions_mut().insert(feature_flags)`, then after calling `next.run(req)`, appends two `Set-Cookie` headers to the response to refresh the expiry.

---

## Template Changes

### `templates.rs`

Add `pub features: FeatureFlags` to every template struct:
- `ErrorTemplate`, `RecipesTemplate`, `RecipeTemplate`, `MenuTemplate`
- `ShoppingListTemplate`, `PantryTemplate`, `PreferencesTemplate`
- `EditTemplate`, `NewTemplate`

### `builders.rs`

The builder input structs for recipes/recipe/menu gain a `features: FeatureFlags` field, which is passed through to the constructed template.

### `ui.rs`

Every handler extracts `Extension(features): Extension<FeatureFlags>` and passes it to the template.

---

## `base.html` — Nav links

Inside the existing nav `<div class="order-3 ...">`, before the preferences button, add:

```html
{% if features.show_shopping_list || features.show_pantry %}
<!-- Recipes link (only shown when at least one other feature is enabled) -->
<a href="{{ prefix }}{% if static_mode %}/index.html{% endif %}"
class="nav-pill {% if active == "recipes" %}active{% endif %}">
{{ tr.t("nav-recipes") }}
</a>

{% if features.show_shopping_list && !static_mode %}
<a href="{{ prefix }}/shopping-list"
class="nav-pill {% if active == "shopping" %}active{% endif %}">
{{ tr.t("nav-shopping-list") }}
</a>
{% endif %}

{% if features.show_pantry && !static_mode %}
<a href="{{ prefix }}/pantry"
class="nav-pill {% if active == "pantry" %}active{% endif %}">
{{ tr.t("nav-pantry") }}
</a>
{% endif %}
{% endif %}
```

The same conditional block is duplicated in the mobile overflow dropdown.

---

## `preferences.html` — Features section

New card added after the Language section:

```html
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-2xl border-2 border-blue-200">
<h2 class="text-lg font-semibold mb-2 text-blue-900">{{ tr.t("pref-features") }}</h2>
<p class="text-sm text-blue-700 mb-4">{{ tr.t("pref-features-desc") }}</p>
<div class="flex flex-wrap gap-3">
<button onclick="toggleFeature('show_shopping_list', {{ features.show_shopping_list }})"
class="px-4 py-2 rounded-lg font-medium transition-all duration-200 border-2 {% if features.show_shopping_list %}bg-gradient-to-r from-orange-500 to-orange-600 text-white border-orange-600 shadow-lg scale-105{% else %}bg-white text-gray-700 border-gray-300 hover:border-orange-400 hover:bg-orange-50 hover:scale-105{% endif %}">
{{ tr.t("nav-shopping-list") }}
</button>
<button onclick="toggleFeature('show_pantry', {{ features.show_pantry }})"
class="px-4 py-2 rounded-lg font-medium transition-all duration-200 border-2 {% if features.show_pantry %}bg-gradient-to-r from-orange-500 to-orange-600 text-white border-orange-600 shadow-lg scale-105{% else %}bg-white text-gray-700 border-gray-300 hover:border-orange-400 hover:bg-orange-50 hover:scale-105{% endif %}">
{{ tr.t("nav-pantry") }}
</button>
</div>
</div>
```

JavaScript (same pattern as `setLanguage`):

```js
function toggleFeature(name, current) {
const val = current ? '0' : '1';
const maxAge = 365 * 24 * 60 * 60;
document.cookie = `${name}=${val}; path={{ prefix }}/; max-age=${maxAge}; SameSite=Lax`;
window.location.reload();
}
```

---

## Translations

Two new keys added to `preferences.ftl` in all 7 locales:

| Locale | `pref-features` | `pref-features-desc` |
|---------|-----------------------|-------------------------------------------------------------------|
| en-US | Features | Choose which features appear in the navigation bar. |
| de-DE | Funktionen | Wähle, welche Funktionen in der Navigationsleiste angezeigt werden. |
| nl-NL | Functies | Kies welke functies in de navigatiebalk worden weergegeven. |
| fr-FR | Fonctionnalités | Choisissez les fonctionnalités à afficher dans la barre de navigation. |
| es-ES | Funcionalidades | Elige qué funciones se muestran en la barra de navegación. |
| eu-ES | Eginbideak | Aukeratu nabigazio-barran zer eginbide erakutsi nahi duzun. |
| sv-SE | Funktioner | Välj vilka funktioner som visas i navigeringsfältet. |

Nav labels (`nav-shopping-list`, `nav-pantry`, `nav-recipes`) already exist in all locales — no changes needed there.

---

## File Checklist

| File | Change |
|------|--------|
| `src/server/language.rs` | Add `FeatureFlags` struct + `features_middleware` |
| `src/server/mod.rs` | Register `features_middleware` in the middleware stack |
| `src/server/templates.rs` | Add `features: FeatureFlags` to all template structs |
| `src/server/builders.rs` | Thread `FeatureFlags` through builder inputs/outputs |
| `src/server/ui.rs` | Extract `Extension(features)` in every handler |
| `templates/base.html` | Add conditional nav links |
| `templates/preferences.html` | Add Features section + `toggleFeature` JS |
| `locales/*/preferences.ftl` (×7) | Add `pref-features` and `pref-features-desc` |
4 changes: 4 additions & 0 deletions locales/de-DE/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Gang-Konfiguration hochladen
pref-upload-pantry = Vorratskammer-Konfiguration hochladen
pref-upload-success = Konfiguration erfolgreich hochgeladen
pref-upload-error = Fehler beim Hochladen der Konfiguration

# Features
pref-features = Funktionen
pref-features-desc = Wähle, welche Funktionen in der Navigationsleiste angezeigt werden.
4 changes: 4 additions & 0 deletions locales/en-US/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Upload Aisle Configuration
pref-upload-pantry = Upload Pantry Configuration
pref-upload-success = Configuration uploaded successfully
pref-upload-error = Error uploading configuration

# Features
pref-features = Features
pref-features-desc = Choose which features appear in the navigation bar.
4 changes: 4 additions & 0 deletions locales/es-ES/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Subir configuración de pasillos
pref-upload-pantry = Subir configuración de despensa
pref-upload-success = Configuración subida con éxito
pref-upload-error = Error al subir la configuración

# Features
pref-features = Funcionalidades
pref-features-desc = Elige qué funciones se muestran en la barra de navegación.
4 changes: 4 additions & 0 deletions locales/eu-ES/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Igo pasilloen konfigurazioa
pref-upload-pantry = Igo despentsaren konfigurazioa
pref-upload-success = Konfigurazioaren igoera arrakastatsua
pref-upload-error = Errorea konfigurazioa igotzerakoan

# Features
pref-features = Eginbideak
pref-features-desc = Aukeratu nabigazio-barran zer eginbide erakutsi nahi duzun.
4 changes: 4 additions & 0 deletions locales/fr-FR/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Télécharger la configuration des allées
pref-upload-pantry = Télécharger la configuration du garde-manger
pref-upload-success = Configuration téléchargée avec succès
pref-upload-error = Erreur lors du téléchargement de la configuration

# Features
pref-features = Fonctionnalités
pref-features-desc = Choisissez les fonctionnalités à afficher dans la barre de navigation.
4 changes: 4 additions & 0 deletions locales/nl-NL/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Gang Configuratie uploaden
pref-upload-pantry = Voorraadkast Configuratie uploaden
pref-upload-success = Configuratie succesvol geüpload
pref-upload-error = Fout bij het uploaden van configuratie

# Features
pref-features = Functies
pref-features-desc = Kies welke functies in de navigatiebalk worden weergegeven.
4 changes: 4 additions & 0 deletions locales/sv-SE/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ pref-upload-aisle = Ladda upp redskap konfiguration
pref-upload-pantry = Ladda upp skafferi konfiguration
pref-upload-success = Konfiguration har laddats upp
pref-upload-error = Fel vid uppladdning av konfiguration

# Features
pref-features = Funktioner
pref-features-desc = Välj vilka funktioner som visas i navigeringsfältet.
4 changes: 4 additions & 0 deletions src/build/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::server::builders::{
build_recipe_template, build_recipes_template, RecipeBuildInput, RecipeBuildOutput,
RecipesBuildInput,
};
use crate::server::language::FeatureFlags;
use anyhow::Result;
use askama::Template;
use camino::{Utf8Path, Utf8PathBuf};
Expand All @@ -24,6 +25,7 @@ pub fn render_index(
sub_path: None,
lang: lang.clone(),
static_mode: true,
features: FeatureFlags::default(),
})?;
let html = template.render()?;
write_html(output, &relpath, &html)
Expand All @@ -45,6 +47,7 @@ pub fn render_directory(
sub_path: Some(sub_path),
lang: lang.clone(),
static_mode: true,
features: FeatureFlags::default(),
})?;
let html = template.render()?;
write_html(output, &relpath, &html)
Expand Down Expand Up @@ -88,6 +91,7 @@ pub fn render_recipe(
scale: 1.0,
lang: lang.clone(),
static_mode: true,
features: FeatureFlags::default(),
})?;

match kind {
Expand Down
11 changes: 11 additions & 0 deletions src/server/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! The builders intentionally avoid any axum / tokio-async types so they can be
//! reused from a non-async context (e.g. `cook build web`).

use crate::server::language::FeatureFlags;
use crate::server::templates::*;
use anyhow::Result;
use camino::{Utf8Path, Utf8PathBuf};
Expand All @@ -18,6 +19,7 @@ pub struct RecipesBuildInput<'a> {
pub sub_path: Option<&'a str>,
pub lang: LanguageIdentifier,
pub static_mode: bool,
pub features: FeatureFlags,
}

/// Build a [`RecipesTemplate`] for either the root or a subdirectory.
Expand All @@ -28,6 +30,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem
sub_path,
lang,
static_mode,
features,
} = input;

let search_path = if let Some(p) = sub_path {
Expand Down Expand Up @@ -151,6 +154,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem
tr: Tr::new(lang),
prefix: url_prefix.to_string(),
static_mode,
features,
})
}

Expand All @@ -163,6 +167,7 @@ pub struct RecipeBuildInput<'a> {
pub scale: f64,
pub lang: LanguageIdentifier,
pub static_mode: bool,
pub features: FeatureFlags,
}

/// Output of [`build_recipe_template`] — either a regular recipe or a menu.
Expand All @@ -181,6 +186,7 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
scale,
lang,
static_mode,
features,
} = input;

let recipe_path_buf = Utf8PathBuf::from(recipe_path);
Expand Down Expand Up @@ -210,6 +216,7 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
url_prefix,
lang,
static_mode,
features,
)?;
return Ok(RecipeBuildOutput::Menu(Box::new(template)));
}
Expand Down Expand Up @@ -722,11 +729,13 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
tr: Tr::new(lang),
prefix: url_prefix.to_string(),
static_mode,
features,
};

Ok(RecipeBuildOutput::Recipe(Box::new(template)))
}

#[allow(clippy::too_many_arguments)]
fn build_menu_template_inner(
path: String,
scale: f64,
Expand All @@ -735,6 +744,7 @@ fn build_menu_template_inner(
url_prefix: &str,
lang: LanguageIdentifier,
static_mode: bool,
features: FeatureFlags,
) -> Result<MenuTemplate> {
let recipe = crate::util::parse_recipe_from_entry(&entry, scale)
.map_err(|e| anyhow::anyhow!("Failed to parse menu: {e}"))?;
Expand Down Expand Up @@ -963,6 +973,7 @@ fn build_menu_template_inner(
tr: Tr::new(lang),
prefix: url_prefix.to_string(),
static_mode,
features,
})
}

Expand Down
Loading
Loading