bloombase/docs/superpowers/plans/2026-05-31-bloombase-redesign.md

1613 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# BloomBase UI Redesign — 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:** Replace Bootstrap 5 with Tailwind CSS, add a 5-tab bottom navigation, extend `Location` with a cover photo field, restyle all existing screens, and add a new Area Detail page — producing the vibrant organic aesthetic from Naomi's mockup.
**Architecture:** New `bloombase.css` holds fonts + CSS blob/flower decorations; `base.html` is fully rewritten with Tailwind CDN + bottom nav; all child templates are rewritten to use Tailwind utilities; one new view + template (`location_detail`) handles the Area Detail screen; `Location` model gains a nullable `cover_photo` ImageField with its own migration.
**Tech Stack:** Django 5.x, HTMX 1.9, Tailwind CSS Play CDN (development), Google Fonts (Great Vibes + Nunito + Pacifico), SQLite
---
## File Map
| File | Action |
|---|---|
| `plants/static/plants/css/bloombase.css` | **Create** — fonts, blob shapes, canvas flowers |
| `plants/templates/plants/base.html` | **Rewrite** — Tailwind, bottom nav, decorations |
| `plants/models.py` | **Modify** — add `cover_photo` to `Location` |
| `plants/migrations/XXXX_location_cover_photo.py` | **Create** via `makemigrations` |
| `plants/forms.py` | **Modify** — add `LocationForm` with `cover_photo` |
| `plants/views/locations.py` | **Modify** — use `LocationForm`; add `location_detail` view |
| `plants/urls.py` | **Modify** — add `location_detail` URL |
| `plants/templates/plants/locations.html` | **Rewrite** — My Garden screen |
| `plants/templates/plants/location_detail.html` | **Create** — Area Detail screen |
| `plants/templates/plants/location_edit.html` | **Rewrite** — add cover photo field |
| `plants/templates/plants/dashboard.html` | **Rewrite** — camera-first Home screen |
| `plants/templates/plants/plant_list.html` | **Rewrite** — search + filter chips |
| `plants/templates/plants/plant_detail.html` | **Rewrite** — hero + care + CTA |
| `plants/templates/plants/identify_upload.html` | **Rewrite** — viewfinder camera UI |
| `plants/templates/plants/identify_confirm.html` | **Rewrite** — match list restyle |
| `plants/tests/test_location_redesign.py` | **Create** — model + view tests |
---
## Task 1: Static CSS — bloombase.css
Creates the custom stylesheet: Google Fonts import, CSS blob background shapes, and fixed-position SVG flower decorations.
**Files:**
- Create: `plants/static/plants/css/bloombase.css`
- [ ] **Step 1.1: Create the static directory and file**
```bash
mkdir -p plants/static/plants/css
```
- [ ] **Step 1.2: Write bloombase.css**
```css
/* plants/static/plants/css/bloombase.css */
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Great+Vibes&family=Pacifico&display=swap');
/* ── Body canvas ── */
body {
background-color: #FFF8F2;
font-family: 'Nunito', sans-serif;
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
/* ── Background blob shapes ── */
.bb-blob-tl {
position: fixed; top: -100px; left: -80px;
width: 380px; height: 380px;
background: radial-gradient(ellipse at 45% 45%, #F9C090 0%, #F07040 55%, #E05020 100%);
border-radius: 62% 38% 54% 46% / 58% 44% 56% 42%;
opacity: 0.18;
pointer-events: none; z-index: 0;
}
.bb-blob-br {
position: fixed; bottom: -80px; right: -60px;
width: 340px; height: 340px;
background: radial-gradient(ellipse at 55% 55%, #FFD080 0%, #F8A040 60%, #F07040 100%);
border-radius: 44% 56% 38% 62% / 52% 48% 52% 48%;
opacity: 0.16;
pointer-events: none; z-index: 0;
}
/* ── Canvas SVG flowers ── */
.bb-flower { position: fixed; pointer-events: none; z-index: 0; }
.bb-flower-tr { top: 16px; right: 24px; }
.bb-flower-ml { top: 38%; left: -4px; }
.bb-flower-br { bottom: 72px; right: 48px; }
.bb-flower-bl { bottom: 88px; left: 32px; }
/* ── Brand fonts ── */
.font-script { font-family: 'Great Vibes', cursive; }
.font-pacifico { font-family: 'Pacifico', cursive; }
.font-nunito { font-family: 'Nunito', sans-serif; }
/* ── Bottom nav stays above content ── */
#bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
background: #fff;
border-top: 1px solid #F0E8DF;
height: 62px;
display: flex; align-items: center; justify-content: space-around;
z-index: 50;
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* ── FAB inside nav ── */
.bb-fab {
width: 44px; height: 44px; border-radius: 50%;
background: linear-gradient(145deg, #F58040, #E84020);
box-shadow: 0 4px 16px rgba(240, 80, 40, 0.5);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 24px; line-height: 1; font-weight: 300;
flex-shrink: 0;
}
/* ── Gradient CTA button ── */
.btn-bloom {
display: block; width: 100%;
background: linear-gradient(135deg, #F58040, #E84020);
color: #fff; border: none; border-radius: 50px;
padding: 12px 24px; font-family: 'Nunito', sans-serif;
font-weight: 800; font-size: 15px; text-align: center;
box-shadow: 0 4px 14px rgba(240,80,40,0.4); cursor: pointer;
text-decoration: none;
}
.btn-bloom-outline {
display: block; width: 100%;
background: transparent; color: #1A1208;
border: 1.5px solid #E0D4C8; border-radius: 50px;
padding: 11px 24px; font-family: 'Nunito', sans-serif;
font-weight: 700; font-size: 15px; text-align: center;
cursor: pointer; text-decoration: none;
}
/* ── White pill button (hero overlays) ── */
.btn-pill {
background: rgba(255,255,255,0.88);
border: none; border-radius: 50px;
padding: 6px 14px; font-size: 13px; font-weight: 700;
color: #1A1208; backdrop-filter: blur(4px); cursor: pointer;
text-decoration: none;
}
/* ── Confidence bar ── */
.conf-track { height: 5px; background: #F0E4D8; border-radius: 3px; overflow: hidden; }
.conf-fill { height: 100%; background: linear-gradient(90deg, #F8A060, #E84020); border-radius: 3px; }
/* ── Area thumbnail gradient placeholders ── */
.at-placeholder-1 { background: linear-gradient(135deg, #88C860, #E85878, #A0D060); }
.at-placeholder-2 { background: linear-gradient(150deg, #B8E060, #F0A840, #E87830); }
.at-placeholder-3 { background: linear-gradient(120deg, #F8B840, #88C060, #F0C860); }
.at-placeholder-4 { background: linear-gradient(140deg, #D0A8E8, #68B858, #C888E0); }
.at-placeholder-5 { background: linear-gradient(130deg, #78D8C8, #E8F0A0, #60C8A8); }
.at-placeholder-6 { background: linear-gradient(125deg, #F8A0B0, #B8E060, #F09060); }
/* ── Viewfinder corners ── */
.vf-corner { position: absolute; width: 18px; height: 18px; border-color: rgba(255,255,255,0.9); border-style: solid; }
.vf-tl { top: 10px; left: 10px; border-width: 2.5px 0 0 2.5px; border-radius: 2px 0 0 0; }
.vf-tr { top: 10px; right: 10px; border-width: 2.5px 2.5px 0 0; border-radius: 0 2px 0 0; }
.vf-bl { bottom: 10px; left: 10px; border-width: 0 0 2.5px 2.5px; border-radius: 0 0 0 2px; }
.vf-br { bottom: 10px; right: 10px; border-width: 0 2.5px 2.5px 0; border-radius: 0 0 2px 0; }
```
- [ ] **Step 1.3: Commit**
```bash
git add plants/static/plants/css/bloombase.css
git commit -m "feat: add bloombase.css — fonts, blobs, canvas flowers, shared components"
```
---
## Task 2: Rewrite base.html
Full replacement of the Bootstrap top-nav shell with Tailwind CDN + 5-tab bottom nav + background canvas decorations.
**Files:**
- Modify: `plants/templates/plants/base.html`
- [ ] **Step 2.1: Replace base.html**
```html
{# plants/templates/plants/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>{% block title %}BloomBase{% endblock %}</title>
{% load static %}
<link rel="stylesheet" href="{% static 'plants/css/bloombase.css' %}">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
bloom: {
bg: '#FFF8F2', primary: '#F07040', green: '#52B788',
dark: '#1A1208', muted: '#B0A090', border: '#F0E4D4',
}
},
fontFamily: {
nunito: ['Nunito', 'sans-serif'],
script: ['"Great Vibes"', 'cursive'],
pacifico: ['Pacifico', 'cursive'],
}
}
}
}
</script>
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-[#FFF8F2]">
<!-- Canvas background blobs -->
<div class="bb-blob-tl"></div>
<div class="bb-blob-br"></div>
<!-- Canvas SVG flowers -->
<svg class="bb-flower bb-flower-tr" width="140" height="140" viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" opacity="0.85">
<defs>
<radialGradient id="hp1" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#A02848"/><stop offset="35%" stop-color="#D04870"/><stop offset="70%" stop-color="#F07898"/><stop offset="100%" stop-color="#FFCCD8"/></radialGradient>
<radialGradient id="hp2" cx="50%" cy="80%" r="85%"><stop offset="0%" stop-color="#B83060"/><stop offset="35%" stop-color="#E06080"/><stop offset="70%" stop-color="#F898B0"/><stop offset="100%" stop-color="#FFDDEA"/></radialGradient>
</defs>
<g transform="translate(70,70)">
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(0)"/>
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(72)" opacity="0.92"/>
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(144)" opacity="0.88"/>
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp2)" transform="rotate(216)" opacity="0.93"/>
<path d="M0,0 C-14,-8 -26,-22 -24,-44 C-22,-62 -10,-68 0,-68 C10,-68 22,-62 24,-44 C26,-22 14,-8 0,0Z" fill="url(#hp1)" transform="rotate(288)" opacity="0.87"/>
<rect x="-3.5" y="-20" width="7" height="20" rx="3.5" fill="#FFD060"/>
<circle r="6" fill="#FFD040"/><circle r="3" fill="#E84020"/>
</g>
</svg>
<svg class="bb-flower bb-flower-ml" width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" opacity="0.7">
<defs><radialGradient id="pp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#C03058"/><stop offset="50%" stop-color="#E86090"/><stop offset="100%" stop-color="#FFB8CC"/></radialGradient></defs>
<g transform="translate(36,36)">
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(0)"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(72)" opacity="0.9"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(144)"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(216)" opacity="0.9"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-24 C-10,-32 -4,-35 0,-35 C4,-35 10,-32 12,-24 C14,-14 8,-5 0,0Z" fill="url(#pp)" transform="rotate(288)"/>
<circle r="5" fill="#FFD040"/><circle r="2.5" fill="#E84020"/>
</g>
</svg>
<svg class="bb-flower bb-flower-br" width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg" opacity="0.8">
<defs><radialGradient id="yp" cx="50%" cy="80%" r="80%"><stop offset="0%" stop-color="#D08000"/><stop offset="50%" stop-color="#F0C020"/><stop offset="100%" stop-color="#FFE880"/></radialGradient></defs>
<g transform="translate(48,48)">
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(0)"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(45)" opacity="0.9"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(90)"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(135)" opacity="0.9"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(180)"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(225)" opacity="0.9"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(270)"/>
<path d="M0,0 C-8,-5 -14,-14 -12,-26 C-10,-36 -4,-40 0,-40 C4,-40 10,-36 12,-26 C14,-14 8,-5 0,0Z" fill="url(#yp)" transform="rotate(315)" opacity="0.9"/>
<circle r="9" fill="#E06010"/><circle r="5" fill="#C04000"/>
</g>
</svg>
<svg class="bb-flower bb-flower-bl" width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg" opacity="0.65">
<g transform="translate(28,28)">
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(0)"/>
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(60)"/>
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(120)"/>
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(180)"/>
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#52B788" transform="rotate(240)"/>
<path d="M0,-20 C4,-20 8,-16 8,-10 C8,-4 4,0 0,0 C-4,0 -8,-4 -8,-10 C-8,-16 -4,-20 0,-20Z" fill="#3A9A68" transform="rotate(300)"/>
</g>
</svg>
<!-- Page content — pb-20 keeps content above fixed bottom nav -->
<main class="relative z-10 pb-24 min-h-screen">
{% block content %}{% endblock %}
</main>
<!-- Bottom navigation -->
<nav id="bottom-nav" aria-label="Main navigation">
{% url 'dashboard' as url_home %}
{% url 'location_list' as url_garden %}
{% url 'identify_upload' as url_identify %}
<!-- Home -->
<a href="{{ url_home }}"
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
{% if active_tab == 'home' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="{% if active_tab == 'home' %}#F07040{% else %}#C8B8A8{% endif %}">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
Home
</a>
<!-- My Garden -->
<a href="{{ url_garden }}"
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
{% if active_tab == 'garden' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="{% if active_tab == 'garden' %}#F07040{% else %}#C8B8A8{% endif %}" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z"/>
</svg>
My Garden
</a>
<!-- FAB: Identify -->
<a href="{{ url_identify }}" class="flex flex-col items-center justify-center flex-1">
<div class="bb-fab">+</div>
</a>
<!-- Care (stub) -->
<a href="#"
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
{% if active_tab == 'care' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="{% if active_tab == 'care' %}#F07040{% else %}#C8B8A8{% endif %}" stroke-width="2"
stroke-linecap="round">
<path d="M12 2a3 3 0 0 1 3 3v7a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z"/>
<path d="M19 10a7 7 0 0 1-14 0"/>
<line x1="12" y1="19" x2="12" y2="22"/>
</svg>
Care
</a>
<!-- Profile (stub) -->
<a href="#"
class="flex flex-col items-center gap-0.5 text-[7.5px] font-bold font-nunito flex-1
{% if active_tab == 'profile' %}text-[#F07040]{% else %}text-[#C8B8A8]{% endif %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="{% if active_tab == 'profile' %}#F07040{% else %}#C8B8A8{% endif %}" stroke-width="2"
stroke-linecap="round">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
Profile
</a>
</nav>
</body>
</html>
```
- [ ] **Step 2.2: Verify the app still loads (no crash)**
```bash
cd /home/stephan/devel/python/plantdb
python manage.py check
```
Expected: `System check identified no issues (0 silenced).`
- [ ] **Step 2.3: Commit**
```bash
git add plants/templates/plants/base.html
git commit -m "feat: rewrite base.html — Tailwind CDN, 5-tab bottom nav, canvas decorations"
```
---
## Task 3: Location model — add cover_photo + migration
**Files:**
- Modify: `plants/models.py`
- Create: migration (via `makemigrations`)
- Create: `plants/tests/test_location_redesign.py`
- [ ] **Step 3.1: Write failing test first**
```python
# plants/tests/test_location_redesign.py
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from plants.models import Location
@pytest.mark.django_db
def test_location_cover_photo_field_exists():
"""Location can be saved with and without a cover_photo."""
loc = Location.objects.create(name='Test Garden')
assert loc.cover_photo.name == '' or loc.cover_photo.name is None
fake_img = SimpleUploadedFile('cover.jpg', b'fake-image-data', content_type='image/jpeg')
loc.cover_photo = fake_img
loc.save()
loc.refresh_from_db()
assert loc.cover_photo.name.startswith('locations/')
```
- [ ] **Step 3.2: Run test — expect failure**
```bash
python -m pytest plants/tests/test_location_redesign.py::test_location_cover_photo_field_exists -v
```
Expected: `FAILED``AttributeError: 'Location' object has no attribute 'cover_photo'`
- [ ] **Step 3.3: Add cover_photo to Location model**
In `plants/models.py`, change:
```python
class Location(models.Model):
name = models.CharField(max_length=200, unique=True)
```
to:
```python
class Location(models.Model):
name = models.CharField(max_length=200, unique=True)
cover_photo = models.ImageField(upload_to='locations/', blank=True, null=True)
```
- [ ] **Step 3.4: Create migration**
```bash
python manage.py makemigrations plants --name location_cover_photo
```
Expected output: `Migrations for 'plants': plants/migrations/XXXX_location_cover_photo.py`
- [ ] **Step 3.5: Apply migration**
```bash
python manage.py migrate
```
Expected: `Applying plants.XXXX_location_cover_photo... OK`
- [ ] **Step 3.6: Run test — expect pass**
```bash
python -m pytest plants/tests/test_location_redesign.py::test_location_cover_photo_field_exists -v
```
Expected: `PASSED`
- [ ] **Step 3.7: Commit**
```bash
git add plants/models.py plants/migrations/ plants/tests/test_location_redesign.py
git commit -m "feat: add Location.cover_photo ImageField + migration + test"
```
---
## Task 4: LocationForm + updated views + new location_detail + URL
**Files:**
- Modify: `plants/forms.py`
- Modify: `plants/views/locations.py`
- Modify: `plants/urls.py`
- Modify: `plants/tests/test_location_redesign.py`
- [ ] **Step 4.1: Write failing tests**
Append to `plants/tests/test_location_redesign.py`:
```python
from django.test import Client
from django.urls import reverse
@pytest.fixture
def client():
return Client()
@pytest.mark.django_db
def test_location_detail_view_returns_200(client):
loc = Location.objects.create(name='My Terrace')
resp = client.get(reverse('location_detail', kwargs={'pk': loc.pk}))
assert resp.status_code == 200
assert b'My Terrace' in resp.content
@pytest.mark.django_db
def test_location_edit_with_cover_photo(client):
loc = Location.objects.create(name='Balcony')
fake_img = SimpleUploadedFile('cover.jpg', b'data', content_type='image/jpeg')
resp = client.post(
reverse('location_edit', kwargs={'pk': loc.pk}),
{'name': 'Balcony Updated', 'cover_photo': fake_img},
)
assert resp.status_code == 302
loc.refresh_from_db()
assert loc.name == 'Balcony Updated'
assert loc.cover_photo.name.startswith('locations/')
```
- [ ] **Step 4.2: Run tests — expect failure**
```bash
python -m pytest plants/tests/test_location_redesign.py -v -k "detail or edit_with_cover"
```
Expected: `FAILED``NoReverseMatch: location_detail` and related errors.
- [ ] **Step 4.3: Add LocationForm to forms.py**
Append to `plants/forms.py`:
```python
class LocationForm(forms.ModelForm):
class Meta:
model = Location
fields = ['name', 'cover_photo']
```
- [ ] **Step 4.4: Rewrite views/locations.py**
```python
# plants/views/locations.py
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.http import require_POST
from django.db.models import Count
from plants.models import Location, Plant
from plants.forms import LocationForm
def location_list(request):
if request.method == 'POST':
name = request.POST.get('name', '').strip()
if name:
Location.objects.get_or_create(name=name)
return redirect('location_list')
locations = Location.objects.annotate(plant_count=Count('plants'))
return render(request, 'plants/locations.html', {
'locations': locations,
'active_tab': 'garden',
})
def location_detail(request, pk):
location = get_object_or_404(Location, pk=pk)
plants = location.plants.select_related('species').prefetch_related('photos').all()
return render(request, 'plants/location_detail.html', {
'location': location,
'plants': plants,
'active_tab': 'garden',
})
def location_edit(request, pk):
location = get_object_or_404(Location, pk=pk)
if request.method == 'POST':
form = LocationForm(request.POST, request.FILES, instance=location)
if form.is_valid():
form.save()
return redirect('location_list')
else:
form = LocationForm(instance=location)
return render(request, 'plants/location_edit.html', {
'location': location,
'form': form,
'active_tab': 'garden',
})
@require_POST
def location_delete(request, pk):
location = get_object_or_404(Location, pk=pk)
Plant.objects.filter(location=location).update(location=None)
location.delete()
return redirect('location_list')
```
- [ ] **Step 4.5: Add location_detail URL**
In `plants/urls.py`, add after the existing `locations/` URL:
```python
path('locations/<int:pk>/', locations.location_detail, name='location_detail'),
```
The full relevant section of urlpatterns should look like:
```python
path('locations/', locations.location_list, name='location_list'),
path('locations/<int:pk>/', locations.location_detail, name='location_detail'),
path('locations/<int:pk>/edit/', locations.location_edit, name='location_edit'),
path('locations/<int:pk>/delete/', locations.location_delete, name='location_delete'),
```
- [ ] **Step 4.6: Run tests — expect pass**
```bash
python -m pytest plants/tests/test_location_redesign.py -v
```
Expected: all `PASSED`
- [ ] **Step 4.7: Commit**
```bash
git add plants/forms.py plants/views/locations.py plants/urls.py plants/tests/test_location_redesign.py
git commit -m "feat: add LocationForm with cover_photo, location_detail view, update location_edit"
```
---
## Task 5: locations.html — My Garden screen
**Files:**
- Modify: `plants/templates/plants/locations.html`
- [ ] **Step 5.1: Rewrite locations.html**
```html
{# plants/templates/plants/locations.html #}
{% extends "plants/base.html" %}
{% block title %}My Garden — BloomBase{% endblock %}
{% block content %}
{% load static %}
<div class="px-4 pt-8 pb-4">
<!-- Header row -->
<div class="flex justify-between items-center mb-4">
<h1 class="font-nunito font-black text-[22px] text-[#1A1208]">My Garden</h1>
<a href="#" class="font-nunito font-bold text-sm text-[#F07040]">Edit</a>
</div>
<!-- Search bar -->
<div class="flex items-center gap-2 bg-[#F5F0EA] rounded-full px-4 py-2.5 mb-4">
<svg width="16" height="16" fill="none" stroke="#B0A090" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<span class="font-nunito text-sm font-semibold text-[#C0B0A0]">Search plants or areas</span>
</div>
<!-- Area rows -->
<div class="flex flex-col gap-3 mb-6">
{% for loc in locations %}
{% with idx=forloop.counter0 %}
<a href="{% url 'location_detail' loc.pk %}"
class="flex items-center bg-white rounded-2xl overflow-hidden shadow-sm no-underline">
<!-- Thumbnail -->
<div class="w-[72px] h-[60px] flex-shrink-0 overflow-hidden">
{% if loc.cover_photo %}
<img src="{{ loc.cover_photo.url }}" alt="{{ loc.name }}"
class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full
{% if idx|divisibleby:5 %}at-placeholder-5
{% elif idx|divisibleby:4 %}at-placeholder-4
{% elif idx|divisibleby:3 %}at-placeholder-3
{% elif idx|divisibleby:2 %}at-placeholder-2
{% else %}at-placeholder-1{% endif %}"></div>
{% endif %}
</div>
<!-- Info -->
<div class="flex-1 px-3">
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ loc.name }}</div>
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5">
{{ loc.plant_count }} plant{{ loc.plant_count|pluralize }}
</div>
</div>
<!-- Chevron -->
<span class="text-[#D0C0B0] font-bold text-lg pr-3"></span>
</a>
{% endwith %}
{% empty %}
<p class="font-nunito text-sm text-[#B0A090] text-center py-8">
No areas yet — add one below!
</p>
{% endfor %}
</div>
<!-- Add area form -->
<form method="post" class="flex gap-2">
{% csrf_token %}
<input type="text" name="name" placeholder="New area name…" required
class="flex-1 bg-white border border-[#F0E4D4] rounded-full px-4 py-2.5
font-nunito text-sm text-[#1A1208] placeholder-[#C0B0A0]
outline-none focus:border-[#F07040]">
<button type="submit" class="bb-fab text-xl font-light flex-shrink-0">+</button>
</form>
</div>
{% endblock %}
```
- [ ] **Step 5.2: Verify page renders**
```bash
python manage.py runserver 0.0.0.0:8000
```
Open http://localhost:8000/locations/ — expect My Garden screen with area rows and + form.
- [ ] **Step 5.3: Commit**
```bash
git add plants/templates/plants/locations.html
git commit -m "feat: restyle locations.html as My Garden screen with cover photo thumbnails"
```
---
## Task 6: location_detail.html — Area Detail screen
**Files:**
- Create: `plants/templates/plants/location_detail.html`
- [ ] **Step 6.1: Create location_detail.html**
```html
{# plants/templates/plants/location_detail.html #}
{% extends "plants/base.html" %}
{% block title %}{{ location.name }} — BloomBase{% endblock %}
{% block content %}
<!-- Cover photo hero -->
<div class="relative h-[180px] overflow-hidden">
{% if location.cover_photo %}
<img src="{{ location.cover_photo.url }}" alt="{{ location.name }}"
class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full at-placeholder-1"></div>
{% endif %}
<!-- Gradient fade at bottom -->
<div class="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-white to-transparent"></div>
<!-- Back button -->
<a href="{% url 'location_list' %}" class="btn-pill absolute top-3 left-3 no-underline">← Back</a>
<!-- Edit button -->
<a href="{% url 'location_edit' location.pk %}" class="btn-pill absolute top-3 right-3 no-underline">Edit</a>
</div>
<div class="px-4 pt-3 pb-4">
<!-- Area name + count -->
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ location.name }}</h1>
<p class="font-nunito text-sm font-semibold text-[#B0A090] mb-5">
{{ plants.count }} plant{{ plants.count|pluralize }}
</p>
<!-- Plant grid -->
{% if plants %}
<div class="grid grid-cols-2 gap-3">
{% for plant in plants %}
<a href="{% url 'plant_detail' plant.pk %}" class="bg-white rounded-2xl overflow-hidden shadow-sm no-underline block">
<!-- Plant image -->
<div class="h-[100px] overflow-hidden">
{% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}"
class="w-full h-full object-cover">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" alt="{{ plant.name }}"
class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full bg-[#D8F3DC] flex items-center justify-center text-3xl">🌿</div>
{% endif %}
</div>
<!-- Plant info -->
<div class="p-3">
<div class="font-nunito font-extrabold text-[12px] text-[#1A1208] leading-tight">{{ plant.name }}</div>
{% if plant.species %}
<div class="font-nunito text-[9px] font-semibold text-[#B0A090] mt-0.5 italic">{{ plant.species.scientific_name }}</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<!-- Empty state -->
<div class="text-center py-12">
<div class="text-5xl mb-3">🪴</div>
<p class="font-nunito font-bold text-[#B0A090] mb-4">No plants here yet.</p>
<a href="{% url 'identify_upload' %}" class="btn-bloom inline-block w-auto px-8">Identify a plant</a>
</div>
{% endif %}
</div>
{% endblock %}
```
- [ ] **Step 6.2: Verify page renders**
Open http://localhost:8000/locations/1/ (replace 1 with a real pk) — expect hero + plant grid.
- [ ] **Step 6.3: Commit**
```bash
git add plants/templates/plants/location_detail.html
git commit -m "feat: add location_detail.html — area detail with cover photo header + plant grid"
```
---
## Task 7: location_edit.html — add cover photo upload
**Files:**
- Modify: `plants/templates/plants/location_edit.html`
- [ ] **Step 7.1: Rewrite location_edit.html**
```html
{# plants/templates/plants/location_edit.html #}
{% extends "plants/base.html" %}
{% block title %}Edit Area — BloomBase{% endblock %}
{% block content %}
<div class="px-4 pt-6 pb-4">
<div class="flex items-center gap-3 mb-6">
<a href="{% url 'location_list' %}" class="btn-pill no-underline text-sm"></a>
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Edit area</h1>
</div>
<form method="post" enctype="multipart/form-data" class="flex flex-col gap-4">
{% csrf_token %}
<!-- Area name -->
<div>
<label class="block font-nunito font-bold text-[11px] text-[#B0A090] uppercase tracking-wider mb-1.5">
Area name
</label>
<input type="text" name="name" value="{{ location.name }}" required autofocus
class="w-full bg-white border border-[#F0E4D4] rounded-2xl px-4 py-3
font-nunito text-[15px] text-[#1A1208] outline-none focus:border-[#F07040]">
</div>
<!-- Cover photo -->
<div>
<label class="block font-nunito font-bold text-[11px] text-[#B0A090] uppercase tracking-wider mb-1.5">
Cover photo
</label>
{% if location.cover_photo %}
<div class="mb-2 rounded-2xl overflow-hidden h-[100px]">
<img src="{{ location.cover_photo.url }}" alt="Current cover"
class="w-full h-full object-cover">
</div>
{% endif %}
<label class="flex items-center gap-3 bg-white border border-[#F0E4D4] rounded-2xl px-4 py-3 cursor-pointer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F07040" stroke-width="2">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<span class="font-nunito font-semibold text-sm text-[#B0A090]">
{% if location.cover_photo %}Replace photo{% else %}Add cover photo{% endif %}
</span>
<input type="file" name="cover_photo" accept="image/*" class="hidden" id="cover-input"
onchange="document.getElementById('cover-label').textContent = this.files[0]?.name || ''">
</label>
<span id="cover-label" class="block mt-1 font-nunito text-[10px] text-[#B0A090] px-1"></span>
</div>
<!-- Actions -->
<div class="flex flex-col gap-2 mt-2">
<button type="submit" class="btn-bloom">Save</button>
<a href="{% url 'location_list' %}" class="btn-bloom-outline text-center no-underline">Cancel</a>
</div>
</form>
</div>
{% endblock %}
```
- [ ] **Step 7.2: Verify form submits with photo**
Open http://localhost:8000/locations/1/edit/ — fill name, attach image, save. Confirm redirect to /locations/ with updated thumbnail.
- [ ] **Step 7.3: Commit**
```bash
git add plants/templates/plants/location_edit.html
git commit -m "feat: restyle location_edit.html with cover photo upload field"
```
---
## Task 8: dashboard.html — camera-first Home screen
Also update `dashboard` view to pass `active_tab`.
**Files:**
- Modify: `plants/views/dashboard.py`
- Modify: `plants/templates/plants/dashboard.html`
- [ ] **Step 8.1: Update dashboard view to pass active_tab and location count**
```python
# plants/views/dashboard.py
from datetime import date
from django.shortcuts import render
from plants.models import Plant, Location
def dashboard(request):
today = date.today()
all_plants = list(Plant.objects.select_related('species').all())
blooming_now = [p for p in all_plants if today.month in (p.bloom_months or [])]
latest_plants = Plant.objects.select_related('species', 'location').prefetch_related('photos').order_by('-pk')[:6]
location_count = Location.objects.count()
return render(request, 'plants/dashboard.html', {
'total_plants': len(all_plants),
'blooming_now': blooming_now,
'blooming_now_count': len(blooming_now),
'today': today,
'latest_plants': latest_plants,
'location_count': location_count,
'active_tab': 'home',
})
```
- [ ] **Step 8.2: Rewrite dashboard.html**
```html
{# plants/templates/plants/dashboard.html #}
{% extends "plants/base.html" %}
{% load plant_extras %}
{% block title %}BloomBase{% endblock %}
{% block content %}
<!-- Top bar: hamburger + bell -->
<div class="flex justify-between items-center px-4 pt-6 pb-2">
<button class="p-1" aria-label="Menu">
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
<button class="p-1" aria-label="Notifications">
<svg width="22" height="22" fill="none" stroke="#1A1208" stroke-width="2" viewBox="0 0 24 24">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
</div>
<div class="px-4">
<!-- Greeting + headline -->
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-0.5">Hello, Plant Lover! 🌿</p>
<h1 class="font-nunito font-black text-[24px] text-[#1A1208] leading-tight mb-1">
What are we <span class="text-[#F07040]">growing</span> today?
</h1>
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
Identify a plant and add it to Bloombase.
</p>
<!-- Camera viewfinder — tap → identify_upload -->
<a href="{% url 'identify_upload' %}" class="block relative bg-[#2C3820] rounded-2xl h-[120px] overflow-hidden mb-4 no-underline">
<!-- Simulated plant in viewfinder -->
<div class="absolute inset-0"
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
radial-gradient(ellipse 40% 35% at 40% 55%, #C04050 0%, #D86070 40%, transparent 70%),
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
<!-- Corner brackets -->
<div class="vf-corner vf-tl"></div>
<div class="vf-corner vf-tr"></div>
<div class="vf-corner vf-bl"></div>
<div class="vf-corner vf-br"></div>
<!-- Shutter + gallery -->
<div class="absolute bottom-3 left-0 right-0 flex justify-center items-center gap-6">
<div class="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center border border-white/40">
<svg width="14" height="14" fill="none" stroke="white" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
</svg>
</div>
<div class="w-10 h-10 rounded-full bb-fab"></div>
</div>
</a>
<!-- My Garden pill -->
<a href="{% url 'location_list' %}"
class="flex items-center gap-3 bg-white/80 rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-5 no-underline">
<span class="text-2xl">🪴</span>
<div class="flex-1">
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">My Garden</div>
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">
{{ total_plants }} Plant{{ total_plants|pluralize }} · {{ location_count }} Area{{ location_count|pluralize }}
</div>
</div>
<span class="text-[#D0C0B0] font-bold text-lg"></span>
</a>
<!-- Recently added -->
{% if latest_plants %}
<h2 class="font-nunito font-black text-[16px] text-[#1A1208] mb-3">🌿 Recently added</h2>
<div class="flex flex-col gap-2 mb-4">
{% for plant in latest_plants %}
<a href="{% url 'plant_detail' plant.pk %}"
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm no-underline">
{% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="44" height="44"
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="44" height="44"
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% else %}
<div class="w-11 h-11 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-xl">🌿</div>
{% endif %}
<div>
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ plant.name }}</div>
<div class="font-nunito text-[10px] font-semibold text-[#B0A090]">{{ plant.location }}</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}
```
- [ ] **Step 8.3: Verify home screen**
Open http://localhost:8000/ — expect camera viewfinder + My Garden pill + recent plants.
- [ ] **Step 8.4: Commit**
```bash
git add plants/views/dashboard.py plants/templates/plants/dashboard.html
git commit -m "feat: restyle dashboard as camera-first Home screen"
```
---
## Task 9: plant_list.html — search + location filter chips
Also update the `plant_list` view to pass `active_tab`.
**Files:**
- Modify: `plants/views/plants.py` (just add `active_tab` to plant_list context)
- Modify: `plants/templates/plants/plant_list.html`
- [ ] **Step 9.1: Add active_tab to plant_list view**
In `plants/views/plants.py`, find the `plant_list` view and add `'active_tab': 'garden'` to the context dict it passes to `render`. (Check the current return statement and add the key alongside existing context keys.)
- [ ] **Step 9.2: Rewrite plant_list.html**
```html
{# plants/templates/plants/plant_list.html #}
{% extends "plants/base.html" %}
{% block title %}Plants — BloomBase{% endblock %}
{% block content %}
<div class="px-4 pt-6">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h1 class="font-nunito font-black text-[22px] text-[#1A1208]">All Plants</h1>
<a href="{% url 'plant_add' %}"
class="bb-fab w-10 h-10 flex items-center justify-center rounded-full no-underline text-xl font-light">+</a>
</div>
<!-- Search -->
<div class="flex items-center gap-2 bg-[#F5F0EA] rounded-full px-4 py-2.5 mb-3">
<svg width="16" height="16" fill="none" stroke="#B0A090" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
type="search"
name="q"
value="{{ q }}"
placeholder="Search plants…"
class="flex-1 bg-transparent font-nunito text-sm font-semibold text-[#1A1208]
placeholder-[#C0B0A0] outline-none"
hx-get="{% url 'plant_list' %}"
hx-trigger="input changed delay:300ms"
hx-target="#plant-list"
hx-swap="innerHTML"
>
</div>
<!-- Location filter chips -->
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 scrollbar-hide">
<a href="{% url 'plant_list' %}"
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
{% if not location_filter %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
no-underline">
All
</a>
{% for loc in locations %}
<a href="{% url 'plant_list' %}?location={{ loc.pk }}"
class="flex-shrink-0 px-4 py-1.5 rounded-full font-nunito font-bold text-[12px]
{% if location_filter == loc.pk|stringformat:'s' %}bg-[#F07040] text-white{% else %}bg-white text-[#B0A090] border border-[#F0E4D4]{% endif %}
no-underline">
{{ loc.name }}
</a>
{% endfor %}
</div>
<!-- Plant list -->
<div id="plant-list">
{% include "plants/partials/plant_list_results.html" %}
</div>
</div>
{% endblock %}
```
- [ ] **Step 9.3: Update plant_list_results partial**
Open `plants/templates/plants/partials/plant_list_results.html` and check its current content. Update it to use Tailwind card style:
```html
{# plants/templates/plants/partials/plant_list_results.html #}
{% for plant in plants %}
<a href="{% url 'plant_detail' plant.pk %}"
class="flex items-center gap-3 bg-white rounded-2xl px-3 py-2.5 shadow-sm mb-2.5 no-underline block">
{% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="52" height="52"
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="52" height="52"
class="rounded-xl flex-shrink-0" style="object-fit:cover;">
{% else %}
<div class="w-[52px] h-[52px] bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="font-nunito font-extrabold text-[14px] text-[#1A1208] truncate">{{ plant.name }}</div>
{% if plant.location %}
<span class="inline-block font-nunito text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#FFF3E8] text-[#F07040] mt-0.5">
{{ plant.location.name }}
</span>
{% endif %}
</div>
<span class="text-[#D0C0B0] font-bold text-lg"></span>
</a>
{% empty %}
<p class="font-nunito text-sm text-[#B0A090] text-center py-8">No plants found.</p>
{% endfor %}
```
- [ ] **Step 9.4: Verify plant list**
Open http://localhost:8000/plants/ — expect search + chips + card list.
- [ ] **Step 9.5: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_list.html plants/templates/plants/partials/plant_list_results.html
git commit -m "feat: restyle plant_list with Tailwind — search, filter chips, plant cards"
```
---
## Task 10: plant_detail.html — hero + care + CTA
Also add `active_tab` to the `plant_detail` view.
**Files:**
- Modify: `plants/views/plants.py` (add `active_tab` to plant_detail context)
- Modify: `plants/templates/plants/plant_detail.html`
- [ ] **Step 10.1: Add active_tab to plant_detail view context**
In `plants/views/plants.py`, find the `plant_detail` view and add `'active_tab': 'garden'` to the context.
- [ ] **Step 10.2: Rewrite plant_detail.html**
```html
{# plants/templates/plants/plant_detail.html #}
{% extends "plants/base.html" %}
{% load plant_extras %}
{% block title %}{{ plant.name }} — BloomBase{% endblock %}
{% block content %}
<!-- Hero image -->
<div class="relative h-[220px] overflow-hidden" id="plant-hero-container">
{% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" alt="{{ plant.name }}" class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full bg-[#D8F3DC] flex items-center justify-center text-6xl">🌿</div>
{% endif %}
<div class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white to-transparent"></div>
<!-- Back -->
<a href="{% url 'plant_list' %}" class="btn-pill absolute top-3 left-3 no-underline">← Back</a>
<!-- Edit -->
<a href="{% url 'plant_edit' plant.pk %}" class="btn-pill absolute top-3 right-3 no-underline">Edit</a>
<!-- Favorite (UI only) -->
<button class="btn-pill absolute top-3 right-16">🤍</button>
</div>
<div class="px-4 pt-3 pb-4">
<!-- Plant name + latin -->
<h1 class="font-nunito font-black text-[22px] text-[#1A1208] mb-0.5">{{ plant.name }}</h1>
{% if plant.species %}
<div class="flex items-center gap-1.5 mb-3">
<span class="font-nunito font-bold text-[13px] text-[#F07040] italic">{{ plant.species.scientific_name }}</span>
<a href="https://www.google.com/search?q={{ plant.species.scientific_name|default:plant.name|urlencode }}"
target="_blank" rel="noopener">
<img src="https://www.google.com/favicon.ico" width="13" height="13" style="opacity:0.65;vertical-align:middle;">
</a>
</div>
{% endif %}
{% if plant.location %}
<p class="font-nunito text-[11px] font-semibold text-[#B0A090] mb-3">
📍 {{ plant.location.name }}
</p>
{% endif %}
<!-- Care row -->
{% if plant.watering or plant.sunlight or plant.species %}
<div class="flex justify-around py-3 border-t border-b border-[#F5EDE4] mb-4">
{% if plant.watering %}
<div class="flex flex-col items-center gap-1">
<span class="text-xl">💧</span>
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_watering_display }}</span>
</div>
{% elif plant.species and plant.species.watering %}
<div class="flex flex-col items-center gap-1">
<span class="text-xl">💧</span>
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.watering }}</span>
</div>
{% endif %}
{% if plant.sunlight %}
<div class="flex flex-col items-center gap-1">
<span class="text-xl">☀️</span>
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.get_sunlight_display }}</span>
</div>
{% elif plant.species and plant.species.sunlight %}
<div class="flex flex-col items-center gap-1">
<span class="text-xl">☀️</span>
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.sunlight }}</span>
</div>
{% endif %}
{% if plant.species and plant.species.frost_hardiness_c is not None %}
<div class="flex flex-col items-center gap-1">
<span class="text-xl">🌡️</span>
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.frost_hardiness_c }}°C min</span>
</div>
{% endif %}
{% if plant.species and plant.species.max_height_cm %}
<div class="flex flex-col items-center gap-1">
<span class="text-xl">📏</span>
<span class="font-nunito font-bold text-[9px] text-[#B0A090] text-center">{{ plant.species.max_height_cm }} cm</span>
</div>
{% endif %}
</div>
{% endif %}
<!-- Bloom strip -->
{% if plant.bloom_months %}
<div class="mb-4 p-3 rounded-2xl bg-[#D8F3DC]">
<p class="font-nunito font-bold text-[11px] text-[#1b4332] mb-1">🌸 Bloom months</p>
{% month_strip plant.bloom_months %}
</div>
{% endif %}
<!-- Notes -->
{% if plant.notes %}
<div class="bg-white rounded-2xl px-4 py-3 border border-[#F0E4D4] mb-4">
<p class="font-nunito text-[13px] text-[#6B7280]">{{ plant.notes|linebreaksbr }}</p>
</div>
{% endif %}
<!-- Pruning -->
{% if plant.pruning_months %}
{% include "plants/partials/pruning_strip.html" %}
{% endif %}
<!-- Photo gallery -->
<div id="photo-gallery" class="mb-4">
{% include "plants/partials/photo_gallery.html" %}
</div>
<!-- Action buttons -->
<div class="flex flex-col gap-2 mt-2">
<a href="{% url 'plant_edit' plant.pk %}" class="btn-bloom no-underline">Edit this plant</a>
<a href="{% url 'plant_card' plant.pk %}" class="btn-bloom-outline no-underline">🪧 Plant card</a>
<a href="{% url 'plant_delete' plant.pk %}"
class="font-nunito font-bold text-[13px] text-center text-[#DC2626] py-2 no-underline">
Delete plant
</a>
</div>
</div>
{% endblock %}
```
- [ ] **Step 10.3: Verify plant detail**
Open http://localhost:8000/plants/1/ — expect hero + care row + CTA buttons.
- [ ] **Step 10.4: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_detail.html
git commit -m "feat: restyle plant_detail — hero, care row, gradient CTA buttons"
```
---
## Task 11: identify_upload.html — viewfinder camera UI
Also add `active_tab` to `identify_upload` view.
**Files:**
- Modify: `plants/views/identify.py` (add `active_tab`)
- Modify: `plants/templates/plants/identify_upload.html`
- [ ] **Step 11.1: Add active_tab to identify_upload view**
In `plants/views/identify.py`, find the `identify_upload` view. Add `'active_tab': 'home'` to the context dict passed to render (for the GET response).
- [ ] **Step 11.2: Rewrite identify_upload.html**
```html
{# plants/templates/plants/identify_upload.html #}
{% extends "plants/base.html" %}
{% block title %}Identify plant — BloomBase{% endblock %}
{% block content %}
<div class="px-4 pt-6">
<!-- Header -->
<div class="flex items-center gap-3 mb-4">
<a href="{% url 'dashboard' %}" class="btn-pill no-underline text-sm"></a>
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Identify plant</h1>
</div>
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
Add one or more photos, tag what each shows, then tap Identify.
</p>
{% if error %}
<div class="bg-orange-50 border border-orange-200 rounded-2xl px-4 py-3 font-nunito text-sm text-orange-700 mb-4">
{{ error }}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="identify-form">
{% csrf_token %}
<input type="file" name="images" accept="image/jpeg,image/png" class="hidden" id="file-picker">
<!-- Viewfinder / drop zone -->
<div class="relative bg-[#2C3820] rounded-2xl h-[140px] overflow-hidden mb-4" id="vf-box">
<div class="absolute inset-0"
style="background: radial-gradient(ellipse 60% 55% at 60% 45%, #C84060 0%, #E05878 30%, #F880A0 55%, transparent 75%),
linear-gradient(160deg, #1E3018 0%, #2E5028 30%, #3A6832 50%, #2C5020 70%);"></div>
<div class="vf-corner vf-tl"></div>
<div class="vf-corner vf-tr"></div>
<div class="vf-corner vf-bl"></div>
<div class="vf-corner vf-br"></div>
<!-- Controls row -->
<div class="absolute bottom-4 left-0 right-0 flex justify-center items-center gap-6">
<label for="file-picker"
class="w-9 h-9 bg-white/20 rounded-xl flex items-center justify-center border border-white/40 cursor-pointer">
<svg width="15" height="15" fill="none" stroke="white" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
</svg>
</label>
<label for="file-picker"
class="bb-fab cursor-pointer">
</label>
</div>
</div>
<!-- Photo rows (populated by JS) -->
<div id="photo-rows" class="mb-4"></div>
<!-- Add more label (shown when < 5 photos) -->
<div id="add-btn-wrap" class="mb-4">
<label for="file-picker"
class="flex items-center justify-center gap-2 bg-white border border-[#F0E4D4] rounded-2xl py-3
font-nunito font-bold text-sm text-[#B0A090] cursor-pointer">
📷 Add photo <span id="photo-count" class="text-[#C0B0A0]"></span>
</label>
</div>
<button type="submit" id="identify-btn" disabled
class="btn-bloom opacity-50 cursor-not-allowed" id="identify-btn">
Identify →
</button>
</form>
<div class="text-center mt-4">
<a href="{% url 'plant_add' %}" class="font-nunito text-sm font-semibold text-[#B0A090] no-underline">
Or enter name manually
</a>
</div>
<!-- Loading overlay -->
<div id="identify-spinner"
style="display:none; position:fixed; inset:0; background:rgba(255,248,242,.92); z-index:9999;
align-items:center; justify-content:center; flex-direction:column; gap:16px;">
<div style="width:40px;height:40px;border:3px solid #F0E4D4;border-top-color:#F07040;
border-radius:50%;animation:spin .8s linear infinite;"></div>
<p class="font-nunito font-bold text-sm text-[#B0A090]">Identifying plant…</p>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
</div>
<script>
const ORGANS = [
{val:'flower',label:'🌸 flower'},{val:'leaf',label:'🍃 leaf'},
{val:'habit',label:'🌿 habit'},{val:'fruit',label:'🍎 fruit'},
{val:'bark',label:'🪵 bark'},{val:'auto',label:'✨ auto'},
];
const selected = [];
const picker = document.getElementById('file-picker');
picker.addEventListener('change', function () {
if (!this.files[0] || selected.length >= 5) return;
selected.push({file: this.files[0], organ: 'auto', objectUrl: URL.createObjectURL(this.files[0])});
this.value = '';
render();
});
function setOrgan(idx, organ) { selected[idx].organ = organ; render(); }
function removePhoto(idx) {
URL.revokeObjectURL(selected[idx].objectUrl);
selected.splice(idx, 1);
render();
}
function render() {
const rows = document.getElementById('photo-rows');
rows.innerHTML = '';
selected.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'flex gap-3 items-start mb-4';
row.innerHTML = `
<div style="position:relative;flex-shrink:0;">
<img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:14px;">
<button type="button" onclick="removePhoto(${i})"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.5);color:#fff;
border:none;border-radius:50%;width:20px;height:20px;font-size:10px;
cursor:pointer;padding:0;line-height:1;">✕</button>
</div>
<div style="flex:1;">
<div style="font-family:Nunito,sans-serif;font-size:11px;font-weight:700;color:#B0A090;margin-bottom:6px;">What's shown?</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;">
${ORGANS.map(o=>`
<span onclick="setOrgan(${i},'${o.val}')"
style="cursor:pointer;padding:3px 10px;border-radius:20px;font-size:11px;font-family:Nunito,sans-serif;font-weight:700;
${item.organ===o.val?'background:#F07040;color:#fff;':'background:#F5F0EA;color:#B0A090;'}">
${o.label}
</span>`).join('')}
</div>
<input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
</div>`;
rows.appendChild(row);
});
document.querySelectorAll('input[name="organs"]').forEach((el,i)=>{
if(selected[i]) el.value=selected[i].organ;
});
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
const btn = document.getElementById('identify-btn');
btn.disabled = selected.length === 0;
btn.style.opacity = selected.length === 0 ? '0.5' : '1';
btn.style.cursor = selected.length === 0 ? 'not-allowed' : 'pointer';
}
document.getElementById('identify-form').addEventListener('submit', function(e) {
if (selected.length === 0) { e.preventDefault(); return; }
const dt = new DataTransfer();
selected.forEach(s => dt.items.add(s.file));
picker.files = dt.files;
document.getElementById('identify-spinner').style.display = 'flex';
});
</script>
{% endblock %}
```
- [ ] **Step 11.3: Verify identify screen**
Open http://localhost:8000/identify/ — expect viewfinder box + shutter button + organ tags.
- [ ] **Step 11.4: Commit**
```bash
git add plants/views/identify.py plants/templates/plants/identify_upload.html
git commit -m "feat: restyle identify_upload as camera-first viewfinder screen"
```
---
## Task 12: identify_confirm.html — match list restyle
**Files:**
- Modify: `plants/templates/plants/identify_confirm.html`
- [ ] **Step 12.1: Rewrite identify_confirm.html**
```html
{# plants/templates/plants/identify_confirm.html #}
{% extends "plants/base.html" %}
{% block title %}Confirm species — BloomBase{% endblock %}
{% block content %}
<div class="px-4 pt-6">
<div class="flex items-center gap-3 mb-2">
<a href="{% url 'identify_upload' %}" class="btn-pill no-underline text-sm"></a>
<h1 class="font-nunito font-black text-[20px] text-[#1A1208]">Is this your plant?</h1>
</div>
<p class="font-nunito font-semibold text-[11px] text-[#B0A090] mb-4">
Tap the correct species, then confirm.
</p>
<form method="post" id="confirm-form">
{% csrf_token %}
<input type="hidden" name="match_idx" id="match_idx_input" value="0">
<div class="flex flex-col gap-3 mb-5" id="match-list">
{% for m in matches %}
<div class="match-card flex items-center gap-3 bg-white rounded-2xl p-3 shadow-sm cursor-pointer
{% if forloop.first %}ring-2 ring-[#F07040]{% endif %}"
data-idx="{{ forloop.counter0 }}"
data-name="{{ m.scientific_name }}"
onclick="selectMatch(this)">
{% if m.image_url %}
<img src="{{ m.image_url }}" alt="{{ m.scientific_name }}"
class="w-16 h-16 rounded-xl flex-shrink-0 object-cover">
{% else %}
<div class="w-16 h-16 bg-[#D8F3DC] rounded-xl flex-shrink-0 flex items-center justify-center text-2xl">🌿</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="font-nunito font-extrabold text-[13px] text-[#1A1208]">{{ m.scientific_name }}</div>
{% if m.common_names %}
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] mt-0.5 truncate">
{{ m.common_names|join:", " }}
</div>
{% endif %}
<div class="font-nunito text-[10px] font-semibold text-[#B0A090] italic">{{ m.family }}</div>
</div>
<span class="font-nunito font-bold text-[11px] px-2 py-1 rounded-full flex-shrink-0
{% if m.score >= 0.70 %}bg-green-100 text-green-700
{% elif m.score >= 0.30 %}bg-orange-100 text-orange-700
{% else %}bg-gray-100 text-gray-500{% endif %}">
{% widthratio m.score 1 100 %}%
</span>
</div>
{% endfor %}
</div>
<button type="submit" class="btn-bloom">
Use <span id="selected-name">{{ matches.0.scientific_name }}</span>
</button>
</form>
</div>
<script>
function selectMatch(el) {
document.querySelectorAll('.match-card').forEach(c => c.classList.remove('ring-2','ring-[#F07040]'));
el.classList.add('ring-2','ring-[#F07040]');
document.getElementById('match_idx_input').value = el.dataset.idx;
document.getElementById('selected-name').textContent = el.dataset.name;
}
</script>
{% endblock %}
```
- [ ] **Step 12.2: Verify identify confirm**
Run a real identification or view the confirm page directly — expect match cards with confidence badges and gradient confirm button.
- [ ] **Step 12.3: Commit**
```bash
git add plants/templates/plants/identify_confirm.html
git commit -m "feat: restyle identify_confirm — match cards with confidence badges"
```
---
## Self-Review
**Spec coverage check:**
| Spec item | Task |
|---|---|
| Replace Bootstrap 5 with Tailwind CSS | Task 2 (base.html) |
| 5-tab bottom nav (Home, Garden, +, Care, Profile) | Task 2 |
| `Location.cover_photo` + migration | Task 3 |
| `LocationForm` with cover_photo | Task 4 |
| `location_detail` view + URL | Task 4 |
| locations.html My Garden screen | Task 5 |
| location_detail.html Area Detail | Task 6 |
| location_edit.html with cover photo | Task 7 |
| dashboard.html camera-first | Task 8 |
| plant_list.html | Task 9 |
| plant_detail.html | Task 10 |
| identify_upload.html | Task 11 |
| identify_confirm.html | Task 12 |
| Canvas blob decorations + SVG flowers | Task 1 + Task 2 |
| Great Vibes + Nunito + Pacifico fonts | Task 1 |
| Gradient CTA buttons | Task 1 (.btn-bloom) |
| `active_tab` nav active state | Tasks 4, 8, 9, 10, 11 |
| Care + Profile stubs | Task 2 (href="#") |
**Gaps identified and resolved:**
- `plant_list_results.html` partial was missing — added in Task 9.3
- `plant_list` and `plant_detail` views need `active_tab` in context — noted in Tasks 9 and 10
- `identify_upload` view needs `active_tab` — noted in Task 11
**Type/name consistency:**
- `active_tab` values: `'home'` (dashboard, identify), `'garden'` (locations, plant_list, plant_detail, location_detail, location_edit)
- CSS class names used consistently: `.btn-bloom`, `.btn-bloom-outline`, `.btn-pill`, `.bb-fab`, `.vf-corner .vf-tl/tr/bl/br`
- `at-placeholder-N` classes 16 defined in bloombase.css and used in locations.html + location_detail.html
**No placeholders:** All template code is complete. All CSS is complete.