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

61 KiB
Raw Permalink Blame History

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

mkdir -p plants/static/plants/css
  • Step 1.2: Write bloombase.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
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

{# 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)
cd /home/stephan/devel/python/plantdb
python manage.py check

Expected: System check identified no issues (0 silenced).

  • Step 2.3: Commit
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

# 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
python -m pytest plants/tests/test_location_redesign.py::test_location_cover_photo_field_exists -v

Expected: FAILEDAttributeError: 'Location' object has no attribute 'cover_photo'

  • Step 3.3: Add cover_photo to Location model

In plants/models.py, change:

class Location(models.Model):
    name = models.CharField(max_length=200, unique=True)

to:

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
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
python manage.py migrate

Expected: Applying plants.XXXX_location_cover_photo... OK

  • Step 3.6: Run test — expect pass
python -m pytest plants/tests/test_location_redesign.py::test_location_cover_photo_field_exists -v

Expected: PASSED

  • Step 3.7: Commit
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:

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
python -m pytest plants/tests/test_location_redesign.py -v -k "detail or edit_with_cover"

Expected: FAILEDNoReverseMatch: location_detail and related errors.

  • Step 4.3: Add LocationForm to forms.py

Append to plants/forms.py:

class LocationForm(forms.ModelForm):
    class Meta:
        model = Location
        fields = ['name', 'cover_photo']
  • Step 4.4: Rewrite views/locations.py
# 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:

path('locations/<int:pk>/', locations.location_detail, name='location_detail'),

The full relevant section of urlpatterns should look like:

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
python -m pytest plants/tests/test_location_redesign.py -v

Expected: all PASSED

  • Step 4.7: Commit
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

{# 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
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
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

{# 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
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

{# 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
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

# 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
{# 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
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
{# 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:

{# 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
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
{# 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
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
{# 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
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

{# 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
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.