Section Icon Picker Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Let users choose an icon from a curated HeroIcons v1 set when creating/editing a section, replacing the config-driven slug-to-icon mapping.
Architecture: Add icon field to section model (backend + frontend), create an IconPicker component for the admin form, update navigation to read icon from section data instead of config lookup, remove navigation.iconMap from site.config.json.
Tech Stack: FastAPI/Pydantic (backend), React/TypeScript (frontend), HeroIcons v1 via react-icons/hi, MongoDB migration via pymongo-migrate.
Task 1: Backend Model — Add icon Field
Files:
- Modify:
backend/models/section.py:1-73 - Test:
backend/tests/test_sections_api.py - Test fixture:
backend/tests/conftest.py:562-576
Step 1: Write the failing test
Add to backend/tests/test_sections_api.py inside TestCreateSection:
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_section_with_icon(
self,
sections_async_client,
override_sections_database,
sample_section_data,
mock_auth,
auth_headers,
):
"""Test section creation with icon field."""
section_id = ObjectId("507f1f77bcf86cd799439011")
override_sections_database.find_one.side_effect = [
None,
{**sample_section_data, "_id": section_id, "slug": "photos", "icon": "camera"},
]
override_sections_database.insert_one.return_value = MagicMock(inserted_id=section_id)
response = await sections_async_client.post(
"/sections",
json={
"title": "Photos",
"display_type": "gallery",
"content_type": "photo_essay",
"icon": "camera",
},
headers=auth_headers,
)
assert response.status_code == 201
assert response.json()["icon"] == "camera"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_section_invalid_icon(
self,
sections_async_client,
override_sections_database,
mock_auth,
auth_headers,
):
"""Test section creation rejects invalid icon value."""
response = await sections_async_client.post(
"/sections",
json={
"title": "Photos",
"display_type": "gallery",
"content_type": "photo_essay",
"icon": "nonexistent-icon",
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_section_default_icon(
self,
sections_async_client,
override_sections_database,
sample_section_data,
mock_auth,
auth_headers,
):
"""Test section creation defaults icon to 'default' when omitted."""
section_id = ObjectId("507f1f77bcf86cd799439011")
override_sections_database.find_one.side_effect = [
None,
{**sample_section_data, "_id": section_id, "slug": "blog", "icon": "default"},
]
override_sections_database.insert_one.return_value = MagicMock(inserted_id=section_id)
response = await sections_async_client.post(
"/sections",
json={
"title": "Blog",
"display_type": "feed",
"content_type": "story",
},
headers=auth_headers,
)
assert response.status_code == 201
assert response.json()["icon"] == "default"Step 2: Run tests to verify they fail
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && source ~/Documents/venvs/field-notes/bin/activate && python -m pytest backend/tests/test_sections_api.py::TestCreateSection::test_create_section_with_icon backend/tests/test_sections_api.py::TestCreateSection::test_create_section_invalid_icon backend/tests/test_sections_api.py::TestCreateSection::test_create_section_default_icon -v
Expected: FAIL
Step 3: Implement the model changes
In backend/models/section.py, add the icon allowlist and icon field to all three models:
VALID_ICONS = {
"home", "user", "folder", "mail", "star", "heart", "tag", "globe",
"chat", "bell", "fire", "thumb-up", "eye", "clock", "shield-check",
"camera", "photograph", "film", "music-note",
"book-open", "document-text", "pencil",
"code", "lightning-bolt", "chip", "beaker", "terminal", "cube",
"color-swatch", "sparkles", "puzzle", "academic-cap", "light-bulb",
"map", "calendar", "collection", "clipboard-list",
"default",
}Add to SectionBase after is_published:
icon: str = Field("default", description="Icon key from HeroIcons v1 set")
@field_validator("icon")
@classmethod
def validate_icon(cls, v: str) -> str:
if v not in VALID_ICONS:
raise ValueError(f"Invalid icon '{v}'. Must be one of: {sorted(VALID_ICONS)}")
return vAdd same icon field + validator to SectionCreate and SectionUpdate (as icon: str | None = None for Update).
Update sample_section_data fixture in backend/tests/conftest.py to include "icon": "default".
Step 4: Run tests to verify they pass
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && python -m pytest backend/tests/test_sections_api.py -v
Expected: ALL PASS
Step 5: Format and commit
cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && make format
git add backend/models/section.py backend/tests/test_sections_api.py backend/tests/conftest.py
git commit -m "feat: add icon field to section model with validation"Task 2: Migration — Backfill Existing Sections
Files:
- Create:
backend/migrations/0010_add_icon_to_sections.py
Step 1: Write the migration
"""Add icon field to sections, backfilling from known slug mappings."""
name = "0010_add_icon_to_sections"
dependencies = ["0009_rename_image_to_photo_essay"]
SLUG_TO_ICON = {
"blog": "home",
"about": "user",
"projects": "folder",
"contact": "mail",
}
def upgrade(db):
# Backfill known slugs with their mapped icons
for slug, icon in SLUG_TO_ICON.items():
db.sections.update_many(
{"slug": slug, "icon": {"$exists": False}},
{"$set": {"icon": icon}},
)
# Set all remaining sections without an icon to "default"
db.sections.update_many(
{"icon": {"$exists": False}},
{"$set": {"icon": "default"}},
)
def downgrade(db):
db.sections.update_many(
{},
{"$unset": {"icon": ""}},
)Step 2: Commit
git add backend/migrations/0010_add_icon_to_sections.py
git commit -m "feat: migration to backfill icon field on sections"Task 3: Frontend Types — Add icon to Section Types
Files:
- Modify:
frontend/src/shared/types/api.ts:158-171(Section interface) - Modify:
frontend/src/shared/types/api.ts:247-257(CreateSectionRequest, UpdateSectionRequest)
Step 1: Add icon field to types
In Section interface (after parent_id):
icon: string;In CreateSectionRequest (after parent_id):
icon?: string;UpdateSectionRequest is Partial<CreateSectionRequest> so it inherits automatically.
Step 2: Commit
git add frontend/src/shared/types/api.ts
git commit -m "feat: add icon field to frontend Section types"Task 4: Expand navIcons.ts to Full Icon Map
Files:
- Modify:
frontend/src/shared/lib/navIcons.ts:1-11
Step 1: Replace the icon map with the full 37-icon set
import React from 'react';
import {
HiHome, HiUser, HiFolder, HiMail, HiStar, HiHeart, HiTag, HiGlobe,
HiChat, HiBell, HiFire, HiThumbUp, HiEye, HiClock, HiShieldCheck,
HiCamera, HiPhotograph, HiFilm, HiMusicNote,
HiBookOpen, HiDocumentText, HiPencil,
HiCode, HiLightningBolt, HiChip, HiBeaker, HiTerminal, HiCube,
HiColorSwatch, HiSparkles, HiPuzzle, HiAcademicCap, HiLightBulb,
HiMap, HiCalendar, HiCollection, HiClipboardList,
HiViewGrid,
} from 'react-icons/hi';
export const SECTION_ICONS = [
'home', 'user', 'folder', 'mail', 'star', 'heart', 'tag', 'globe',
'chat', 'bell', 'fire', 'thumb-up', 'eye', 'clock', 'shield-check',
'camera', 'photograph', 'film', 'music-note',
'book-open', 'document-text', 'pencil',
'code', 'lightning-bolt', 'chip', 'beaker', 'terminal', 'cube',
'color-swatch', 'sparkles', 'puzzle', 'academic-cap', 'light-bulb',
'map', 'calendar', 'collection', 'clipboard-list',
'default',
] as const;
export type SectionIcon = (typeof SECTION_ICONS)[number];
export const iconMap: Record<SectionIcon, React.ComponentType<{ className?: string }>> = {
home: HiHome,
user: HiUser,
folder: HiFolder,
mail: HiMail,
star: HiStar,
heart: HiHeart,
tag: HiTag,
globe: HiGlobe,
chat: HiChat,
bell: HiBell,
fire: HiFire,
'thumb-up': HiThumbUp,
eye: HiEye,
clock: HiClock,
'shield-check': HiShieldCheck,
camera: HiCamera,
photograph: HiPhotograph,
film: HiFilm,
'music-note': HiMusicNote,
'book-open': HiBookOpen,
'document-text': HiDocumentText,
pencil: HiPencil,
code: HiCode,
'lightning-bolt': HiLightningBolt,
chip: HiChip,
beaker: HiBeaker,
terminal: HiTerminal,
cube: HiCube,
'color-swatch': HiColorSwatch,
sparkles: HiSparkles,
puzzle: HiPuzzle,
'academic-cap': HiAcademicCap,
'light-bulb': HiLightBulb,
map: HiMap,
calendar: HiCalendar,
collection: HiCollection,
'clipboard-list': HiClipboardList,
default: HiViewGrid,
};Step 2: Commit
git add frontend/src/shared/lib/navIcons.ts
git commit -m "feat: expand icon map to 37 curated HeroIcons"Task 5: Update Navigation — Read Icon from Section Data
Files:
- Modify:
frontend/src/shared/lib/navigation.ts:1-39 - Modify:
frontend/src/hooks/useNavSections.ts:1-51 - Modify:
frontend/src/config/types.ts:9-28 - Modify:
frontend/site.config.json:18-25
Step 1: Simplify navigation.ts
Remove the config-based icon lookup. NavIcon type replaced by SectionIcon from navIcons.ts. sectionToNavItem reads section.icon directly.
import { Section } from '@/shared/types/api';
import { SectionIcon, SECTION_ICONS } from '@/shared/lib/navIcons';
const VALID_ICON_SET = new Set<string>(SECTION_ICONS);
export interface NavSectionItem {
id: string;
slug: string;
path: string;
label: string;
icon: SectionIcon;
}
export function sectionToNavItem(section: Section): NavSectionItem {
return {
id: section.id,
slug: section.slug,
path: `/${section.slug}`,
label: section.title,
icon: VALID_ICON_SET.has(section.icon) ? (section.icon as SectionIcon) : 'default',
};
}
export function sectionsToNavItems(sections: Section[]): NavSectionItem[] {
return sections.map(sectionToNavItem);
}
const DEFAULT_SECTION_SLUG = 'blog';
export function getActiveSectionSlug(pathname: string, sections: NavSectionItem[]): string {
const firstSegment = pathname.split('/').filter(Boolean)[0];
if (!firstSegment) return DEFAULT_SECTION_SLUG;
return sections.find(s => s.slug === firstSegment)?.slug || DEFAULT_SECTION_SLUG;
}Step 2: Update useNavSections.ts
Remove getSiteConfig import and iconMap parameter from sectionsToNavItems call:
import { useState, useEffect } from 'react';
import apiClient from '@/shared/lib/api-client';
import { sectionsToNavItems, NavSectionItem } from '@/shared/lib/navigation';
let cachedSections: NavSectionItem[] | null = null;
let fetchPromise: Promise<NavSectionItem[]> | null = null;
let listeners: Array<() => void> = [];
export function invalidateNavCache() {
cachedSections = null;
fetchPromise = null;
listeners.forEach(fn => fn());
}
function fetchNavSections(): Promise<NavSectionItem[]> {
if (cachedSections) return Promise.resolve(cachedSections);
if (fetchPromise) return fetchPromise;
fetchPromise = apiClient.sections.navigation()
.then((response: { items: import('@/shared/types/api').Section[] }) => {
const items = sectionsToNavItems(response.items);
cachedSections = items;
return cachedSections;
})
.catch(() => {
return cachedSections ?? [];
})
.finally(() => {
fetchPromise = null;
});
return fetchPromise;
}
export function useNavSections(): NavSectionItem[] {
const [sections, setSections] = useState<NavSectionItem[]>(cachedSections || []);
const [version, setVersion] = useState(0);
useEffect(() => {
const listener = () => setVersion((v: number) => v + 1);
listeners.push(listener);
return () => { listeners = listeners.filter(l => l !== listener); };
}, []);
useEffect(() => {
fetchNavSections().then(setSections);
}, [version]);
return sections;
}Step 3: Remove navigation.iconMap from config
In frontend/site.config.json, remove the navigation block entirely (leaving the rest):
{
"site": { ... },
"template": { ... },
"hero": { ... },
"fonts": { ... },
"footer": { ... }
}In frontend/src/config/types.ts, remove the navigation property from SiteConfig:
export interface SiteConfig {
site: {
title: string;
tagline: string;
author: string;
copyright: string;
};
template: TemplateConfig;
hero: HeroConfig;
fonts: {
heading: string;
body: string;
};
footer: {
links: Array<{ label: string; href: string }>;
};
}Step 4: Fix any remaining imports of NavIcon type
The Footer.tsx and HamburgerMenu.tsx already use iconMap from navIcons.ts and access section.icon — these should continue working since NavSectionItem.icon is now typed as SectionIcon instead of NavIcon. Verify no compile errors.
Step 5: Run frontend type check
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker/frontend && npx tsc --noEmit
Expected: PASS (no type errors)
Step 6: Commit
cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker
make format
git add frontend/src/shared/lib/navigation.ts frontend/src/hooks/useNavSections.ts frontend/src/config/types.ts frontend/site.config.json
git commit -m "refactor: read icon from section data, remove config iconMap"Task 6: IconPicker Component
Files:
- Create:
frontend/src/components/IconPicker.tsx
Step 1: Build the component
import React from 'react';
import { SECTION_ICONS, SectionIcon, iconMap } from '@/shared/lib/navIcons';
interface IconPickerProps {
value: SectionIcon;
onChange: (icon: SectionIcon) => void;
disabled?: boolean;
}
const SELECTABLE_ICONS = SECTION_ICONS.filter(icon => icon !== 'default');
export default function IconPicker({ value, onChange, disabled }: IconPickerProps) {
return (
<div>
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Icon</span>
<div className="grid grid-cols-6 sm:grid-cols-9 gap-1" data-testid="icon-picker">
{SELECTABLE_ICONS.map(key => {
const Icon = iconMap[key];
const selected = value === key;
return (
<button
key={key}
type="button"
onClick={() => onChange(key)}
disabled={disabled}
title={key}
className={`p-2 rounded transition-colors ${
selected
? 'ring-2 ring-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
data-testid={`icon-option-${key}`}
aria-pressed={selected}
>
<Icon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
</button>
);
})}
</div>
</div>
);
}Step 2: Commit
git add frontend/src/components/IconPicker.tsx
git commit -m "feat: add IconPicker grid component"Task 7: Add Icon Picker to Section Admin Forms
Files:
- Modify:
frontend/src/pages/admin/sections.tsx:1-320
Step 1: Update SectionCreateForm
Add icon state, import IconPicker and SectionIcon, add picker to form, include icon in submit payload.
In the imports at top of file, add:
import IconPicker from '@/components/IconPicker';
import { SectionIcon } from '@/shared/lib/navIcons';In SectionCreateForm, add state:
const [icon, setIcon] = useState<SectionIcon>('default');In handleSubmit, add icon to payload:
onSubmit({ title: title.trim(), display_type: displayType, content_type: contentType, nav_visibility: navVisibility, icon });Add <IconPicker value={icon} onChange={setIcon} disabled={disabled} /> after the select grid, before the submit button.
Step 2: Update SectionEditForm
Same pattern — add icon state initialized from section.icon, add picker, include in submit.
Add state:
const [icon, setIcon] = useState<SectionIcon>((section.icon || 'default') as SectionIcon);In handleSubmit, add icon:
onSubmit({ title: title.trim(), display_type: displayType, content_type: contentType, nav_visibility: navVisibility, icon });Add <IconPicker value={icon} onChange={setIcon} disabled={disabled} /> in the form.
Step 3: Update SectionRow to show icon
Add icon display next to section title:
import { iconMap } from '@/shared/lib/navIcons';
import { SectionIcon } from '@/shared/lib/navIcons';
// Inside SectionRow, before the title span:
const Icon = iconMap[(section.icon || 'default') as SectionIcon];
// In the JSX, wrap the title line:
<div>
<Icon className="inline-block w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" />
<span className="font-medium text-text-primary">{section.title}</span>
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">/{section.slug}</span>
</div>Step 4: Run type check and lint
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker/frontend && npx tsc --noEmit && npm run lint
Expected: PASS
Step 5: Commit
cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker
make format
git add frontend/src/pages/admin/sections.tsx frontend/src/components/IconPicker.tsx
git commit -m "feat: add icon picker to section create/edit forms and admin list"Task 8: Run Full Test Suite
Step 1: Backend tests
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && make test
Expected: ALL PASS
Step 2: Frontend tests
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && make test-frontend-unit
Expected: ALL PASS
Step 3: Format check
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && make format-check
Expected: PASS
Step 4: Fix any failures, commit fixes
Task 9: Update E2E Test Data
Files:
- Modify:
frontend/e2e/test-data.ts— addiconfield to any section test data objects
Search for section objects in test data and add icon: 'default' (or appropriate value) to each. This ensures e2e tests don’t break from the new required field.
Run: cd /Users/ghostmonk/Documents/code/field-notes/.worktrees/section-icon-picker && make test-frontend-unit
Commit:
git add frontend/e2e/test-data.ts
git commit -m "test: add icon field to e2e section test data"