` widget titles (moved to sidebar wrapper) |
+| `css/tailwind.css` | Add `.widget-header` and `.widget-collapsible` styles |
+
+## Files NOT Modified
+
+- `tailwind.config.js` — no config changes needed (using arbitrary values)
+- `_data/*.js` — no data changes
+- `eleventy.config.js` — no config changes
+- `indiekit-endpoint-homepage/` — no plugin changes
+- `indiekit-endpoint-cv/` — no plugin changes
+
+## Testing
+
+1. Verify homepage renders correctly with all three changes
+2. Test accordion open/close on projects section
+3. Test sidebar collapse/expand and localStorage persistence (close browser, reopen, verify state)
+4. Test dark mode for all color-coded borders
+5. Test mobile responsiveness (sidebar stacks to full-width, widgets should still be collapsible)
+6. Verify h-card microformat markup is preserved in the author-card widget
+7. Verify the /cv/ page is unaffected (cv-projects on /cv/ uses a different template or the same template — if same, accordion applies there too, which is acceptable)
+8. Visual check with playwright-cli on the live site after deployment
+
+## Risks
+
+- **Widget title extraction:** Moving titles from individual widget files to the wrapper requires updating 10+ files. Risk of missing one or breaking a title.
+- **localStorage key collisions:** Using `widget-{type}` as keys. If the same widget type appears twice in the sidebar config, they'd share state. Mitigate by using `widget-{index}` or `widget-{type}-{index}`.
+- **Alpine.js load order:** Widgets wrapped in `` may not have Alpine.js available when the wrapper tries to initialize. Solution: the wrapper's `x-data` is outside ``, so Alpine handles the toggle, and `` handles lazy-loading the widget content inside.
diff --git a/docs/plans/2026-02-24-homepage-ui-ux-plan.md b/docs/plans/2026-02-24-homepage-ui-ux-plan.md
new file mode 100644
index 0000000..61cff4b
--- /dev/null
+++ b/docs/plans/2026-02-24-homepage-ui-ux-plan.md
@@ -0,0 +1,592 @@
+# Homepage UI/UX Improvements — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Improve homepage scannability by adding post card color-coded borders, collapsible project accordion, and collapsible sidebar widgets.
+
+**Architecture:** Three independent rendering changes in the Eleventy theme's Nunjucks templates + Tailwind CSS. No data model changes. Alpine.js handles all interactivity (already loaded). localStorage persists sidebar widget collapse state.
+
+**Tech Stack:** Nunjucks templates, Tailwind CSS (arbitrary values), Alpine.js, localStorage
+
+**Design doc:** `docs/plans/2026-02-24-homepage-ui-ux-design.md`
+
+---
+
+## Task 1: Post Card Color-Coded Left Borders
+
+**Files:**
+- Modify: `_includes/components/sections/recent-posts.njk`
+
+This is the simplest change — add a `border-l-[3px]` class with a type-specific color to each `` element. The template already branches by post type (like, bookmark, repost, reply, photo, article, note), so each branch gets its own color.
+
+**Color mapping (from design doc):**
+| Post Type | Classes |
+|-----------|---------|
+| Like | `border-l-[3px] border-l-red-400 dark:border-l-red-500` |
+| Bookmark | `border-l-[3px] border-l-amber-400 dark:border-l-amber-500` |
+| Repost | `border-l-[3px] border-l-green-400 dark:border-l-green-500` |
+| Reply | `border-l-[3px] border-l-primary-400 dark:border-l-primary-500` |
+| Photo | `border-l-[3px] border-l-purple-400 dark:border-l-purple-500` |
+| Article | `border-l-[3px] border-l-surface-300 dark:border-l-surface-600` |
+| Note | `border-l-[3px] border-l-surface-300 dark:border-l-surface-600` |
+
+### Step 1: Implement the color-coded borders
+
+The `` tag on **line 19** is shared by ALL post types. The type detection happens INSIDE the article (lines 22-27 set variables, lines 28-226 branch by type). Since we need different border colors per type, we must move the `` tag inside each branch, OR use a Nunjucks variable to set the border class before the article opens.
+
+**Approach:** Set a border class variable before the `` tag using Nunjucks `{% set %}` blocks. This keeps the single `` tag and avoids duplicating it 7 times.
+
+In `_includes/components/sections/recent-posts.njk`, replace the block from line 18 through line 28 (the `{% for %}`, type detection variables, and `` opening) with this version that sets a border class variable:
+
+**Current (lines 18-19):**
+```nunjucks
+ {% for post in collections.posts | head(maxItems) %}
+
+```
+
+**After:** Insert the type detection BEFORE the `` tag, set a `borderClass` variable, and add it to the article's class list:
+
+```nunjucks
+ {% for post in collections.posts | head(maxItems) %}
+
+ {# Detect post type for color-coded left border #}
+ {% set likedUrl = post.data.likeOf or post.data.like_of %}
+ {% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
+ {% set repostedUrl = post.data.repostOf or post.data.repost_of %}
+ {% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
+ {% set hasPhotos = post.data.photo and post.data.photo.length %}
+
+ {% if likedUrl %}
+ {% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
+ {% elif bookmarkedUrl %}
+ {% set borderClass = "border-l-[3px] border-l-amber-400 dark:border-l-amber-500" %}
+ {% elif repostedUrl %}
+ {% set borderClass = "border-l-[3px] border-l-green-400 dark:border-l-green-500" %}
+ {% elif replyToUrl %}
+ {% set borderClass = "border-l-[3px] border-l-primary-400 dark:border-l-primary-500" %}
+ {% elif hasPhotos %}
+ {% set borderClass = "border-l-[3px] border-l-purple-400 dark:border-l-purple-500" %}
+ {% else %}
+ {% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
+ {% endif %}
+
+
+```
+
+Then **remove** the duplicate type detection variables that currently exist inside the article (lines 22-26), since they've been moved above. The rest of the template still uses these same variable names in the `{% if likedUrl %}` / `{% elif %}` branches, so those continue to work — the variables are already set.
+
+**Important:** The type detection variables (`likedUrl`, `bookmarkedUrl`, `repostedUrl`, `replyToUrl`, `hasPhotos`) are currently declared on lines 22-26 inside the ``. After this change, they're declared before the ``. Since they're still within the same `{% for %}` loop scope, all subsequent `{% if %}` checks on lines 28+ continue to reference them correctly. Remove lines 22-26 to avoid redeclaring the same variables.
+
+### Step 2: Build and verify
+
+Run:
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+npm run build
+```
+
+Expected: Build completes with exit 0, no template errors.
+
+### Step 3: Visual verification with playwright-cli
+
+```bash
+playwright-cli open https://rmendes.net
+playwright-cli snapshot
+```
+
+Verify: Post cards in "Recent Posts" section have colored left borders (red for likes, green for reposts, etc.). Take a screenshot for evidence.
+
+### Step 4: Commit
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+git add _includes/components/sections/recent-posts.njk
+git commit -m "feat: add color-coded left borders to post cards by type"
+```
+
+---
+
+## Task 2: Projects Accordion
+
+**Files:**
+- Modify: `_includes/components/sections/cv-projects.njk`
+
+Convert the always-expanded 2-column project cards grid into an Alpine.js accordion. Each card shows a collapsed summary row (name + status badge + date range + chevron) and expands on click to reveal description + technology tags.
+
+### Step 1: Implement the accordion
+
+Replace the entire content of `_includes/components/sections/cv-projects.njk` with the accordion version.
+
+**Current behavior:** Lines 16-58 render a `grid grid-cols-1 sm:grid-cols-2 gap-4` with each card showing name, status, dates, description, and tech tags all at once.
+
+**New behavior:** Same grid layout, but each card has:
+- A clickable summary row (always visible): project name (linked if URL), status badge, date range, chevron icon
+- A collapsible detail section (hidden by default): description + tech tags, revealed with `x-show` + `x-transition`
+
+**Full replacement for `cv-projects.njk`:**
+
+```nunjucks
+{#
+ CV Projects Section - collapsible project cards (accordion)
+ Data fetched from /cv/data.json via homepage plugin
+#}
+
+{% set sectionConfig = section.config or {} %}
+{% set maxItems = sectionConfig.maxItems or 10 %}
+{% set showTechnologies = sectionConfig.showTechnologies if sectionConfig.showTechnologies is defined else true %}
+
+{% if cv and cv.projects and cv.projects.length %}
+
+
+ {{ sectionConfig.title or "Projects" }}
+
+
+
+ {% for item in cv.projects | head(maxItems) %}
+
+ {# Summary row — always visible, clickable #}
+
+
+ {# Detail section — collapsible #}
+
+ {% if item.startDate %}
+
+ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
+
+ {% endif %}
+
+ {% if item.description %}
+ {{ item.description }}
+ {% endif %}
+
+ {% if showTechnologies and item.technologies and item.technologies.length %}
+
+ {% for tech in item.technologies %}
+
+ {{ tech }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+{% endif %}
+```
+
+**Key details:**
+- `x-data="{ expanded: {} }"` on the `` — object-based tracking, independent toggles
+- `@click.stop` on the project name `` link — prevents the button click handler from firing when clicking the link
+- Date range shown in summary row on `sm:` screens, and duplicated inside the collapsible detail for mobile (`sm:hidden`)
+- `x-cloak` hides detail sections during Alpine.js initialization
+- `x-transition` with opacity + translate-y for smooth reveal
+- Chevron rotates 180deg via `:class="expanded[index] && 'rotate-180'"`
+- `aria-expanded` attribute for accessibility
+
+### Step 2: Build and verify
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+npm run build
+```
+
+Expected: Exit 0.
+
+### Step 3: Visual verification with playwright-cli
+
+```bash
+playwright-cli open https://rmendes.net
+playwright-cli snapshot
+```
+
+Verify: Projects section shows collapsed cards with name + status + date + chevron. Click a project card to expand — description and tech tags appear with smooth animation.
+
+### Step 4: Commit
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+git add _includes/components/sections/cv-projects.njk
+git commit -m "feat: convert projects section to collapsible accordion"
+```
+
+---
+
+## Task 3: Sidebar Widget Collapsibility
+
+**Files:**
+- Modify: `_includes/components/homepage-sidebar.njk` — add collapsible wrapper
+- Modify: `css/tailwind.css` — add `.widget-header` and `.widget-collapsible` styles
+- Modify: 10 widget files — remove `` titles (moved to sidebar wrapper)
+
+This is the most complex change. The sidebar dispatcher wraps each widget in a collapsible Alpine.js container. The wrapper provides the `` title + chevron toggle, and a CSS rule hides the inner widget title to avoid duplication.
+
+### Step 1: Add CSS classes for widget collapsibility
+
+In `css/tailwind.css`, add these classes inside the existing `@layer components` block (after the `.widget-title` rule, around line 293):
+
+```css
+ /* Collapsible widget wrapper */
+ .widget-header {
+ @apply flex items-center justify-between cursor-pointer;
+ }
+
+ .widget-header .widget-title {
+ @apply mb-0;
+ }
+
+ .widget-chevron {
+ @apply w-4 h-4 text-surface-400 transition-transform duration-200 shrink-0;
+ }
+
+ /* Hide inner widget titles when the collapsible wrapper provides one */
+ .widget-collapsible .widget .widget-title {
+ @apply hidden;
+ }
+
+ /* Hide FeedLand's custom title in collapsible wrapper */
+ .widget-collapsible .widget .fl-title {
+ @apply hidden;
+ }
+```
+
+### Step 2: Rewrite homepage-sidebar.njk with collapsible wrapper
+
+Replace the entire content of `_includes/components/homepage-sidebar.njk`.
+
+**Widget title map** (from design doc):
+
+| widget.type | Title |
+|-------------|-------|
+| search | Search |
+| social-activity | Social Activity |
+| github-repos | GitHub |
+| funkwhale | Listening |
+| recent-posts | Recent Posts |
+| blogroll | Blogroll |
+| feedland | FeedLand |
+| categories | Categories |
+| webmentions | Webmentions |
+| recent-comments | Recent Comments |
+| fediverse-follow | Fediverse |
+| author-card | Author |
+| custom-html | (from widget.config.title or "Custom") |
+
+**New `homepage-sidebar.njk`:**
+
+```nunjucks
+{# Homepage Builder Sidebar — renders widgets from homepageConfig.sidebar #}
+{# Each widget is wrapped in a collapsible container with localStorage persistence #}
+{% if homepageConfig.sidebar and homepageConfig.sidebar.length %}
+ {% for widget in homepageConfig.sidebar %}
+
+ {# Resolve widget title #}
+ {% if widget.type == "search" %}{% set widgetTitle = "Search" %}
+ {% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
+ {% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
+ {% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
+ {% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
+ {% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
+ {% elif widget.type == "feedland" %}{% set widgetTitle = "FeedLand" %}
+ {% elif widget.type == "categories" %}{% set widgetTitle = "Categories" %}
+ {% elif widget.type == "webmentions" %}{% set widgetTitle = "Webmentions" %}
+ {% elif widget.type == "recent-comments" %}{% set widgetTitle = "Recent Comments" %}
+ {% elif widget.type == "fediverse-follow" %}{% set widgetTitle = "Fediverse" %}
+ {% elif widget.type == "author-card" %}{% set widgetTitle = "Author" %}
+ {% elif widget.type == "custom-html" %}{% set widgetTitle = (widget.config.title if widget.config and widget.config.title) or "Custom" %}
+ {% else %}{% set widgetTitle = widget.type %}
+ {% endif %}
+
+ {% set widgetKey = "widget-" + widget.type + "-" + loop.index0 %}
+ {% set defaultOpen = "true" if loop.index0 < 3 else "false" %}
+
+ {# Collapsible wrapper — Alpine.js handles toggle, localStorage persists state #}
+
+
+ {% endfor %}
+{% endif %}
+```
+
+**Key architecture decisions:**
+- The wrapper provides the outer card styling (`bg-white`, `rounded-lg`, `border`, `shadow-sm`) and the title + chevron
+- The inner widget files keep their `.widget` class, but the inner title is hidden via CSS `.widget-collapsible .widget .widget-title { display: none; }`
+- The `search` widget was previously inline in the sidebar — it's now included directly (no separate file), with the inner `` removed since the wrapper provides it
+- The `custom-html` widget's inner `` is removed — the wrapper uses `widget.config.title` or "Custom"
+- The `` wrappers remain inside the individual widget files — the collapsible wrapper is OUTSIDE ``, so the toggle works immediately even before the lazy-loaded content initializes
+- `widgetKey` uses `widget.type + "-" + loop.index0` to avoid localStorage key collisions if the same widget type appears twice
+- First 3 widgets open by default (`loop.index0 < 3`), rest collapsed
+
+**Note on widget `.widget` class and double borders:** The wrapper div already has `bg-white rounded-lg border shadow-sm`, and the inner `.widget` class also has those styles. To avoid double borders/shadows, we need to neutralize the inner `.widget` styling when inside `.widget-collapsible`. Add this to the CSS in Step 1:
+
+```css
+ /* Neutralize inner widget card styling when inside collapsible wrapper */
+ .widget-collapsible .widget {
+ @apply border-0 shadow-none rounded-none mb-0 bg-transparent;
+ }
+```
+
+### Step 3: Build and verify
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+npm run build
+```
+
+Expected: Exit 0.
+
+### Step 4: Visual verification with playwright-cli
+
+```bash
+playwright-cli open https://rmendes.net
+playwright-cli snapshot
+```
+
+Verify:
+- All sidebar widgets have a title + chevron header
+- First 3 widgets are expanded, remaining are collapsed
+- Click a collapsed widget title → it expands smoothly
+- Click an expanded widget title → it collapses
+- No duplicate titles visible (inner titles hidden)
+- No double borders or shadows on widget cards
+
+### Step 5: Test localStorage persistence
+
+```bash
+playwright-cli click # Toggle a widget
+playwright-cli eval "localStorage.getItem('widget-social-activity-1')"
+playwright-cli close
+playwright-cli open https://rmendes.net
+playwright-cli snapshot
+```
+
+Verify: The widget you toggled retains its state after page reload.
+
+### Step 6: Verify dark mode
+
+```bash
+playwright-cli eval "document.documentElement.classList.add('dark')"
+playwright-cli screenshot --filename=dark-mode-sidebar
+```
+
+Verify: Widget headers, chevrons, and collapsed/expanded states look correct in dark mode.
+
+### Step 7: Verify mobile responsiveness
+
+```bash
+playwright-cli resize 375 812
+playwright-cli snapshot
+```
+
+Verify: Sidebar stacks below main content, widgets are still collapsible.
+
+### Step 8: Commit
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+git add _includes/components/homepage-sidebar.njk css/tailwind.css
+git commit -m "feat: add collapsible sidebar widgets with localStorage persistence"
+```
+
+---
+
+## Task 4: Deploy and Final Verification
+
+**Files:** None (deployment commands only)
+
+### Step 1: Push theme repo
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme
+git push origin main
+```
+
+### Step 2: Update submodule in indiekit-cloudron
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-cloudron
+git submodule update --remote eleventy-site
+git add eleventy-site
+git commit -m "chore: update eleventy-site submodule (homepage UI/UX improvements)"
+git push origin main
+```
+
+### Step 3: Build and deploy
+
+```bash
+cd /home/rick/code/indiekit-dev/indiekit-cloudron
+make prepare
+cloudron build --no-cache && cloudron update --app rmendes.net --no-backup
+```
+
+### Step 4: Final visual verification on live site
+
+```bash
+playwright-cli open https://rmendes.net
+playwright-cli screenshot --filename=homepage-final
+playwright-cli snapshot
+```
+
+Verify all three changes are live:
+1. Post cards have color-coded left borders
+2. Projects section is collapsible (all collapsed by default)
+3. Sidebar widgets are collapsible (first 3 open, rest collapsed)
+4. Dark mode works for all changes
+5. h-card (Author widget) is present and contains proper microformat markup
+
+---
+
+## Risk Mitigation Notes
+
+1. **`` + Alpine.js interaction:** The collapsible wrapper's `x-data` is OUTSIDE ``. Alpine.js initializes the toggle immediately. The `` inside widget files handles lazy-loading the widget content. This means the toggle button works before the widget content loads — expanding an unloaded widget triggers `` visibility, which then loads the content.
+
+2. **FeedLand widget:** Uses custom `fl-title` instead of `widget-title`. The CSS rule `.widget-collapsible .widget .fl-title { display: none; }` handles this case.
+
+3. **Author card widget:** Has no inner `` — it just includes `h-card.njk`. The CSS hiding rule won't find anything to hide, which is fine. The wrapper provides "Author" as the title.
+
+4. **Search widget:** Was previously inline in the sidebar with its own `` + ``. Now it's inline inside the collapsible wrapper with the `` removed. The `` wrapper is preserved inside for lazy-loading Pagefind.
+
+ **Wait — re-reading the new sidebar template:** The search widget was changed to NOT use `` in the inline version. Let me note this: the search widget should keep its `` wrapper inside the collapsible content div. Update the search case to:
+ ```nunjucks
+ {% elif widget.type == "search" %}
+
+
+
+ ```
+
+5. **Custom HTML widget:** Similarly should keep `` wrapper:
+ ```nunjucks
+ {% elif widget.type == "custom-html" %}
+ {% set wConfig = widget.config or {} %}
+
+
+
+ ```
+ {{ sectionConfig.title or "Projects" }} +
+ ++ {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %} +
+ {% endif %} + + {% if item.description %} +{{ item.description }}
+ {% endif %} + + {% if showTechnologies and item.technologies and item.technologies.length %} +