feat: add Markdown for Agents — serve clean Markdown to AI agents
Generate index.md alongside index.html for /articles/ at build time. Agents can access clean Markdown via .md URL extension or Accept: text/markdown content negotiation. Includes configurable content-signal policy (ai-train, search, ai-input) and a master on/off toggle via MARKDOWN_AGENTS_ENABLED env var.
This commit is contained in:
@@ -127,4 +127,13 @@ export default {
|
|||||||
lightning: process.env.SUPPORT_LIGHTNING_ADDRESS || null,
|
lightning: process.env.SUPPORT_LIGHTNING_ADDRESS || null,
|
||||||
paymentPointer: process.env.SUPPORT_PAYMENT_POINTER || null,
|
paymentPointer: process.env.SUPPORT_PAYMENT_POINTER || null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Markdown for Agents — serve clean Markdown to AI agents
|
||||||
|
// Set MARKDOWN_AGENTS_ENABLED to "false" to disable entirely
|
||||||
|
markdownAgents: {
|
||||||
|
enabled: (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true",
|
||||||
|
aiTrain: process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes",
|
||||||
|
search: process.env.MARKDOWN_AGENTS_SEARCH || "yes",
|
||||||
|
aiInput: process.env.MARKDOWN_AGENTS_AI_INPUT || "yes",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ site.locale | default('en') }}">
|
<html lang="{{ site.locale | default('en') }}">
|
||||||
<head>
|
<head>
|
||||||
|
{# CRITICAL: Capture page.url IMMEDIATELY — Eleventy 3.x race condition (#3183)
|
||||||
|
causes page.url to change mid-render during parallel processing.
|
||||||
|
Nunjucks {% set %} captures the VALUE (not a reference), making it immune
|
||||||
|
to later mutations of the shared page object. This MUST be the first
|
||||||
|
statement in the template, before any filter calls that could yield. #}
|
||||||
|
{% set _pageUrl = page.url %}
|
||||||
|
{% set _ogSlug = (_pageUrl or "") | ogSlug %}
|
||||||
|
{% set _hasOg = _ogSlug | hasOgImage %}
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="generator" content="Eleventy">
|
<meta name="generator" content="Eleventy">
|
||||||
@@ -17,17 +25,13 @@
|
|||||||
{% set ogPhoto = ogPhoto[0] %}
|
{% set ogPhoto = ogPhoto[0] %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<!-- debug:og _pageUrl={{ _pageUrl }} ogSlug={{ _ogSlug }} hasOg={{ _hasOg }} -->
|
||||||
<meta property="og:title" content="{{ ogTitle }}">
|
<meta property="og:title" content="{{ ogTitle }}">
|
||||||
<meta property="og:site_name" content="{{ site.name }}">
|
<meta property="og:site_name" content="{{ site.name }}">
|
||||||
<meta property="og:url" content="{{ site.url }}{{ page.url }}">
|
<meta property="og:url" content="{{ site.url }}{{ _pageUrl }}">
|
||||||
<meta property="og:type" content="{% if page.url == '/' %}website{% else %}article{% endif %}">
|
<meta property="og:type" content="{% if _pageUrl == '/' %}website{% else %}article{% endif %}">
|
||||||
<meta property="og:description" content="{{ ogDesc }}">
|
<meta property="og:description" content="{{ ogDesc }}">
|
||||||
<meta name="description" content="{{ ogDesc }}">
|
<meta name="description" content="{{ ogDesc }}">
|
||||||
{# Compute OG slug from page.url — NOT permalink or eleventyComputed values.
|
|
||||||
page.url may be false for pages with permalink:false (e.g., about.njk),
|
|
||||||
so guard with (page.url or ""). The ogSlug filter handles empty strings. #}
|
|
||||||
{% set _ogSlug = (page.url or "") | ogSlug %}
|
|
||||||
{% set _hasOg = _ogSlug | hasOgImage %}
|
|
||||||
{% if ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
|
{% if ogPhoto and ogPhoto != "" and (ogPhoto | length) > 10 %}
|
||||||
<meta property="og:image" content="{% if 'http' in ogPhoto %}{{ ogPhoto }}{% else %}{{ site.url }}{% if ogPhoto[0] != '/' %}/{% endif %}{{ ogPhoto }}{% endif %}">
|
<meta property="og:image" content="{% if 'http' in ogPhoto %}{{ ogPhoto }}{% else %}{{ site.url }}{% if ogPhoto[0] != '/' %}/{% endif %}{{ ogPhoto }}{% endif %}">
|
||||||
{% elif image and image != "" and (image | length) > 10 %}
|
{% elif image and image != "" and (image | length) > 10 %}
|
||||||
@@ -98,14 +102,17 @@
|
|||||||
[x-show*="loading"], button[\\@click*="fetch"], button[\\@click*="loadMore"] { display: none !important; }
|
[x-show*="loading"], button[\\@click*="fetch"], button[\\@click*="loadMore"] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
</noscript>
|
</noscript>
|
||||||
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
|
<link rel="canonical" href="{{ site.url }}{{ _pageUrl }}">
|
||||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" title="RSS Feed">
|
<link rel="alternate" type="application/rss+xml" href="/feed.xml" title="RSS Feed">
|
||||||
<link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed">
|
<link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed">
|
||||||
|
{% if site.markdownAgents.enabled and _pageUrl and _pageUrl.startsWith('/articles/') %}
|
||||||
|
<link rel="alternate" type="text/markdown" href="{{ _pageUrl }}index.md" title="Markdown version">
|
||||||
|
{% endif %}
|
||||||
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
|
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
|
||||||
<link rel="token_endpoint" href="{{ site.url }}/auth/token">
|
<link rel="token_endpoint" href="{{ site.url }}/auth/token">
|
||||||
<link rel="micropub" href="{{ site.url }}/micropub">
|
<link rel="micropub" href="{{ site.url }}/micropub">
|
||||||
<link rel="microsub" href="{{ site.url }}/microsub">
|
<link rel="microsub" href="{{ site.url }}/microsub">
|
||||||
<link rel="self" href="{{ site.url }}{{ page.url }}">
|
<link rel="self" href="{{ site.url }}{{ _pageUrl }}">
|
||||||
<link rel="hub" href="https://websubhub.com/hub">
|
<link rel="hub" href="https://websubhub.com/hub">
|
||||||
<link rel="webmention" href="https://webmention.io/{{ site.webmentions.domain }}/webmention">
|
<link rel="webmention" href="https://webmention.io/{{ site.webmentions.domain }}/webmention">
|
||||||
<link rel="pingback" href="https://webmention.io/{{ site.webmentions.domain }}/xmlrpc">
|
<link rel="pingback" href="https://webmention.io/{{ site.webmentions.domain }}/xmlrpc">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---js
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
data: "collections.articles",
|
||||||
|
size: 1,
|
||||||
|
alias: "article"
|
||||||
|
},
|
||||||
|
permalink: function(data) {
|
||||||
|
if (!data.site.markdownAgents.enabled) return false;
|
||||||
|
return data.article.url + "index.md";
|
||||||
|
},
|
||||||
|
eleventyExcludeFromCollections: true
|
||||||
|
}
|
||||||
|
---
|
||||||
|
{%- set bodyContent = article.template.frontMatter.content -%}
|
||||||
|
{%- set tokens = (bodyContent.length / 4) | round(0, "ceil") -%}
|
||||||
|
---
|
||||||
|
title: "{{ article.data.title | replace('"', '\\"') }}"
|
||||||
|
date: {{ article.date.toISOString() }}
|
||||||
|
author: {{ site.author.name }}
|
||||||
|
url: {{ site.url }}{{ article.url }}
|
||||||
|
{%- if article.data.category %}
|
||||||
|
categories:
|
||||||
|
{%- for cat in article.data.category %}
|
||||||
|
- {{ cat }}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- endif %}
|
||||||
|
{%- if article.data.description %}
|
||||||
|
description: "{{ article.data.description | replace('"', '\\"') }}"
|
||||||
|
{%- endif %}
|
||||||
|
tokens: {{ tokens }}
|
||||||
|
content_signal: ai-train={{ site.markdownAgents.aiTrain }}, search={{ site.markdownAgents.search }}, ai-input={{ site.markdownAgents.aiInput }}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{ article.data.title }}
|
||||||
|
|
||||||
|
{{ bodyContent }}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Homepage UI/UX Improvements — Design Document
|
||||||
|
|
||||||
|
**Date:** 2026-02-24
|
||||||
|
**Scope:** indiekit-eleventy-theme (rendering layer only)
|
||||||
|
**Status:** APPROVED
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The homepage at rmendes.net is a data-driven page controlled by the `indiekit-endpoint-homepage` plugin. The plugin's admin UI determines which sections and sidebar widgets appear. This design addresses rendering quality improvements to three areas without changing the data model or plugin architecture.
|
||||||
|
|
||||||
|
The homepage uses a `two-column` layout with a full-width hero, main content sections (Recent Posts, Personal Skills, Personal Interests, Personal Projects), and a sidebar with 7+ widgets (Search, Social Activity, Recent Comments, Webmentions, Blogroll, GitHub, Listening, Author h-card).
|
||||||
|
|
||||||
|
### Design principles
|
||||||
|
|
||||||
|
- Improve visual quality of existing templates, not content decisions
|
||||||
|
- Content selection stays with the user via the homepage plugin config
|
||||||
|
- All changes are in the Eleventy theme (Nunjucks templates + Tailwind CSS)
|
||||||
|
- Use Alpine.js for interactivity (already loaded throughout the theme)
|
||||||
|
- Respect the personal vs work data split (homepage = personal, /cv/ = work)
|
||||||
|
- Never remove IndieWeb infrastructure (h-card, webmentions, microformats)
|
||||||
|
|
||||||
|
## Change 1: Projects Accordion
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The `cv-projects` section renders full paragraph descriptions for every project. On the homepage, 5 projects with multi-line descriptions dominate the page, pushing sidebar content far below the viewport. The section reads like a resume rather than a scannable overview.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
Convert project cards from always-expanded to an accordion pattern using Alpine.js.
|
||||||
|
|
||||||
|
**Collapsed state (default):** Single row showing:
|
||||||
|
- Project name (linked if URL exists)
|
||||||
|
- Status badge (active/maintained/archived/completed)
|
||||||
|
- Date range (e.g., "2022-02 – Present")
|
||||||
|
- Chevron toggle icon (right-aligned)
|
||||||
|
|
||||||
|
**Expanded state (on click):** Full card content:
|
||||||
|
- Description paragraph
|
||||||
|
- Technology tags
|
||||||
|
- Smooth reveal via `x-transition`
|
||||||
|
|
||||||
|
**File:** `_includes/components/sections/cv-projects.njk`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- All projects start collapsed on page load
|
||||||
|
- Click anywhere on the summary row to toggle
|
||||||
|
- Multiple projects can be open simultaneously (independent toggles, not mutual exclusion)
|
||||||
|
- The 2-column grid layout is preserved — each card in the grid is independently collapsible
|
||||||
|
- Chevron rotates 180deg when expanded
|
||||||
|
|
||||||
|
**Markup pattern:**
|
||||||
|
```html
|
||||||
|
<section x-data="{ expanded: {} }">
|
||||||
|
<!-- For each project -->
|
||||||
|
<div class="project-card">
|
||||||
|
<button @click="expanded[index] = !expanded[index]">
|
||||||
|
<h3>Name</h3> <span>status</span> <span>dates</span> <chevron :class="expanded[index] && 'rotate-180'">
|
||||||
|
</button>
|
||||||
|
<div x-show="expanded[index]" x-transition>
|
||||||
|
<p>description</p>
|
||||||
|
<div>tech tags</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual details:**
|
||||||
|
- Summary row: `flex items-center justify-between` with `cursor-pointer`
|
||||||
|
- Hover: `hover:bg-surface-50 dark:hover:bg-surface-700/50` on the summary row
|
||||||
|
- Chevron: `w-4 h-4 text-surface-400 transition-transform duration-200`
|
||||||
|
- Transition: `x-transition:enter="transition ease-out duration-200"` with opacity + translate-y
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
Reduces vertical space of the projects section by ~70% in collapsed state. Visitors can scan project names and drill into details on interest.
|
||||||
|
|
||||||
|
## Change 2: Sidebar Widget Collapsibility
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The sidebar has 7+ widgets stacked vertically, each fully expanded. The sidebar is longer than the main content area, and widgets below the fold (GitHub, Listening, Author h-card) are only reachable after significant scrolling.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
Add a collapsible wrapper around each widget in `homepage-sidebar.njk`. Widget titles become clickable toggle buttons with a chevron indicator. Collapse state persists in `localStorage`.
|
||||||
|
|
||||||
|
**Default state (first visit):**
|
||||||
|
- First 3 widgets in the sidebar config: **open**
|
||||||
|
- Remaining widgets: **collapsed** (title + chevron visible)
|
||||||
|
|
||||||
|
**Return visits:** `localStorage` restores the user's last toggle state for each widget.
|
||||||
|
|
||||||
|
**Files changed:**
|
||||||
|
- `_includes/components/homepage-sidebar.njk` — add wrapper around each widget include
|
||||||
|
- `css/tailwind.css` — add `.widget-collapsible` styles
|
||||||
|
- Individual widget files — extract `<h3>` title to be passed as a variable OR keep title inside but hide it when the wrapper provides one
|
||||||
|
|
||||||
|
**Architecture decision:** The wrapper approach. Rather than modifying 10+ individual widget files, the sidebar dispatcher wraps each widget include in a collapsible container. This requires knowing the widget title at the dispatcher level.
|
||||||
|
|
||||||
|
**Title resolution:** Each widget type has a known title (Search, Social Activity, GitHub, Listening, Blogroll, etc.). The dispatcher maps `widget.type` to a display title, or uses `widget.config.title` if set. The individual widget files keep their own `<h3>` tags — the wrapper hides the inner title via CSS when the wrapper provides one, or we remove the inner `<h3>` from widget files and let the wrapper handle all titles uniformly.
|
||||||
|
|
||||||
|
**Recommended approach:** Remove `<h3>` from individual widget files and let the wrapper handle titles. This is cleaner and avoids duplicate headings. Each widget file keeps its content only.
|
||||||
|
|
||||||
|
**Markup pattern:**
|
||||||
|
```html
|
||||||
|
{% set widgetTitle = "Social Activity" %}
|
||||||
|
{% set widgetKey = "widget-social-activity" %}
|
||||||
|
{% set defaultOpen = loop.index0 < 3 %}
|
||||||
|
|
||||||
|
<div class="widget" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }">
|
||||||
|
<button
|
||||||
|
class="widget-header"
|
||||||
|
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
|
||||||
|
aria-expanded="open"
|
||||||
|
>
|
||||||
|
<h3 class="widget-title">{{ widgetTitle }}</h3>
|
||||||
|
<svg :class="open && 'rotate-180'" class="chevron">...</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-transition x-cloak>
|
||||||
|
{% include "components/widgets/social-activity.njk" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual details:**
|
||||||
|
- Widget header: `flex items-center justify-between cursor-pointer`
|
||||||
|
- Chevron: `w-4 h-4 text-surface-400 transition-transform duration-200`
|
||||||
|
- No visual change when open — widget looks exactly as it does today
|
||||||
|
- When collapsed: only the header row (title + chevron) is visible, with the existing widget border/background
|
||||||
|
- Smooth transition: `x-transition:enter="transition ease-out duration-150"`
|
||||||
|
|
||||||
|
**Widget title map:**
|
||||||
|
|
||||||
|
| 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") |
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
Reduces initial sidebar scroll length. Visitors see all widget titles at a glance and expand what interests them. First-time visitors get a curated view (top 3 open), returning visitors get their preferred configuration.
|
||||||
|
|
||||||
|
## Change 3: Post Card Color-Coded Left Borders
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
All post cards in the `recent-posts` section use identical styling (white bg, gray border, rounded-lg). When scrolling a mixed feed of notes, reposts, replies, likes, bookmarks, and photos, the only way to distinguish post types is by reading the small icon + label text inside each card. There's no scannable visual signal at the card level.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
Add a `border-l-3` (3px left border) to each `<article>` in `recent-posts.njk`, colored by post type. The colors match the existing SVG icon colors already used inside the cards.
|
||||||
|
|
||||||
|
**Color mapping:**
|
||||||
|
|
||||||
|
| Post Type | Left Border Color | Matches Existing |
|
||||||
|
|-----------|------------------|-----------------|
|
||||||
|
| Like | `border-l-red-400` | `text-red-500` heart icon |
|
||||||
|
| Bookmark | `border-l-amber-400` | `text-amber-500` bookmark icon |
|
||||||
|
| Repost | `border-l-green-400` | `text-green-500` repost icon |
|
||||||
|
| Reply | `border-l-primary-400` | `text-primary-500` reply icon |
|
||||||
|
| Photo | `border-l-purple-400` | `text-purple-500` camera icon |
|
||||||
|
| Article | `border-l-surface-300 dark:border-l-surface-600` | Neutral, matches header text weight |
|
||||||
|
| Note | `border-l-surface-300 dark:border-l-surface-600` | Neutral, matches header text weight |
|
||||||
|
|
||||||
|
**File:** `_includes/components/sections/recent-posts.njk`
|
||||||
|
|
||||||
|
**Implementation:** Add the border class to each `<article>` element. The template already branches by post type (like, bookmark, repost, reply, photo, article, note) so each branch gets its specific border color.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```html
|
||||||
|
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (example for repost):**
|
||||||
|
```html
|
||||||
|
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 border-l-3 border-l-green-400 dark:border-l-green-500 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual details:**
|
||||||
|
- `border-l-3` (3px) is enough to be noticeable without being heavy
|
||||||
|
- The left border color is constant (doesn't change on hover) — the top/right/bottom borders still change to primary on hover
|
||||||
|
- Dark mode uses slightly brighter variants (400 in light, 500 in dark) for visibility
|
||||||
|
- `rounded-lg` still applies — the left border gets a subtle radius at top-left and bottom-left corners
|
||||||
|
|
||||||
|
**Tailwind note:** `border-l-3` is not a default Tailwind class. Options:
|
||||||
|
1. Use `border-l-4` (4px, default Tailwind) — slightly thicker but no config change
|
||||||
|
2. Add `borderWidth: { 3: '3px' }` to `tailwind.config.js` extend — exact 3px
|
||||||
|
3. Use arbitrary value `border-l-[3px]` — works without config change
|
||||||
|
|
||||||
|
**Recommendation:** Use `border-l-[3px]` (arbitrary value). No config change needed, exact width desired.
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
Instant visual scanability of the feed. Visitors can quickly identify post types by color without reading text labels. The feed feels more alive and differentiated.
|
||||||
|
|
||||||
|
## Files Modified (Summary)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `_includes/components/sections/cv-projects.njk` | Alpine.js accordion with collapsed summary rows |
|
||||||
|
| `_includes/components/sections/recent-posts.njk` | Add `border-l-[3px]` with type-specific colors to each article |
|
||||||
|
| `_includes/components/homepage-sidebar.njk` | Collapsible wrapper around each widget with localStorage persistence |
|
||||||
|
| `_includes/components/widgets/*.njk` (10+ files) | Remove `<h3>` 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 `<is-land on:visible>` may not have Alpine.js available when the wrapper tries to initialize. Solution: the wrapper's `x-data` is outside `<is-land>`, so Alpine handles the toggle, and `<is-land>` handles lazy-loading the widget content inside.
|
||||||
@@ -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 `<article>` 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 `<article>` 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 `<article>` 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 `<article>` tag using Nunjucks `{% set %}` blocks. This keeps the single `<article>` 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 `<article>` opening) with this version that sets a border class variable:
|
||||||
|
|
||||||
|
**Current (lines 18-19):**
|
||||||
|
```nunjucks
|
||||||
|
{% for post in collections.posts | head(maxItems) %}
|
||||||
|
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:** Insert the type detection BEFORE the `<article>` 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 %}
|
||||||
|
|
||||||
|
<article class="h-entry p-4 bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 {{ borderClass }} hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `<article>`. After this change, they're declared before the `<article>`. 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 %}
|
||||||
|
<section class="mb-8 sm:mb-12" id="projects" x-data="{ expanded: {} }">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6">
|
||||||
|
{{ sectionConfig.title or "Projects" }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{% for item in cv.projects | head(maxItems) %}
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-600 transition-colors overflow-hidden">
|
||||||
|
{# Summary row — always visible, clickable #}
|
||||||
|
<button
|
||||||
|
class="w-full p-4 flex items-center justify-between gap-2 cursor-pointer text-left hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors"
|
||||||
|
@click="expanded[{{ loop.index0 }}] = !expanded[{{ loop.index0 }}]"
|
||||||
|
:aria-expanded="expanded[{{ loop.index0 }}] ? 'true' : 'false'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<h3 class="font-semibold text-surface-900 dark:text-surface-100 truncate">
|
||||||
|
{% if item.url %}
|
||||||
|
<a href="{{ item.url }}" class="hover:text-primary-600 dark:hover:text-primary-400" @click.stop>{{ item.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ item.name }}
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if item.status %}
|
||||||
|
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full capitalize
|
||||||
|
{% if item.status == 'active' %}bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300
|
||||||
|
{% elif item.status == 'maintained' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
|
||||||
|
{% elif item.status == 'archived' %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400
|
||||||
|
{% else %}bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400{% endif %}">
|
||||||
|
{{ item.status }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if item.startDate %}
|
||||||
|
<span class="text-xs text-surface-500 hidden sm:inline">
|
||||||
|
{{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-surface-400 transition-transform duration-200"
|
||||||
|
:class="expanded[{{ loop.index0 }}] && 'rotate-180'"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Detail section — collapsible #}
|
||||||
|
<div
|
||||||
|
x-show="expanded[{{ loop.index0 }}]"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-1"
|
||||||
|
x-cloak
|
||||||
|
class="px-4 pb-4"
|
||||||
|
>
|
||||||
|
{% if item.startDate %}
|
||||||
|
<p class="text-xs text-surface-500 mb-1 sm:hidden">
|
||||||
|
{{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.description %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">{{ item.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if showTechnologies and item.technologies and item.technologies.length %}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for tech in item.technologies %}
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-400 rounded">
|
||||||
|
{{ tech }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key details:**
|
||||||
|
- `x-data="{ expanded: {} }"` on the `<section>` — object-based tracking, independent toggles
|
||||||
|
- `@click.stop` on the project name `<a>` 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 `<h3>` 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 `<h3>` 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 #}
|
||||||
|
<div
|
||||||
|
class="widget-collapsible mb-4"
|
||||||
|
x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : {{ defaultOpen }} }"
|
||||||
|
>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
class="widget-header w-full p-4"
|
||||||
|
@click="open = !open; localStorage.setItem('{{ widgetKey }}', open)"
|
||||||
|
:aria-expanded="open ? 'true' : 'false'"
|
||||||
|
>
|
||||||
|
<h3 class="widget-title font-bold text-lg">{{ widgetTitle }}</h3>
|
||||||
|
<svg
|
||||||
|
class="widget-chevron"
|
||||||
|
:class="open && 'rotate-180'"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition:enter="transition ease-out duration-150"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-100"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
{# Widget content — inner .widget provides padding, inner title hidden by CSS #}
|
||||||
|
{% if widget.type == "author-card" %}
|
||||||
|
{% include "components/widgets/author-card.njk" %}
|
||||||
|
{% elif widget.type == "social-activity" %}
|
||||||
|
{% include "components/widgets/social-activity.njk" %}
|
||||||
|
{% elif widget.type == "github-repos" %}
|
||||||
|
{% include "components/widgets/github-repos.njk" %}
|
||||||
|
{% elif widget.type == "funkwhale" %}
|
||||||
|
{% include "components/widgets/funkwhale.njk" %}
|
||||||
|
{% elif widget.type == "recent-posts" %}
|
||||||
|
{% include "components/widgets/recent-posts.njk" %}
|
||||||
|
{% elif widget.type == "blogroll" %}
|
||||||
|
{% include "components/widgets/blogroll.njk" %}
|
||||||
|
{% elif widget.type == "feedland" %}
|
||||||
|
{% include "components/widgets/feedland.njk" %}
|
||||||
|
{% elif widget.type == "categories" %}
|
||||||
|
{% include "components/widgets/categories.njk" %}
|
||||||
|
{% elif widget.type == "search" %}
|
||||||
|
<div class="widget">
|
||||||
|
<div id="sidebar-search"></div>
|
||||||
|
<script>initPagefind("#sidebar-search");</script>
|
||||||
|
</div>
|
||||||
|
{% elif widget.type == "webmentions" %}
|
||||||
|
{% include "components/widgets/webmentions.njk" %}
|
||||||
|
{% elif widget.type == "recent-comments" %}
|
||||||
|
{% include "components/widgets/recent-comments.njk" %}
|
||||||
|
{% elif widget.type == "fediverse-follow" %}
|
||||||
|
{% include "components/widgets/fediverse-follow.njk" %}
|
||||||
|
{% elif widget.type == "custom-html" %}
|
||||||
|
{% set wConfig = widget.config or {} %}
|
||||||
|
<div class="widget">
|
||||||
|
{% if wConfig.content %}
|
||||||
|
<div class="prose dark:prose-invert prose-sm max-w-none">
|
||||||
|
{{ wConfig.content | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Unknown widget type: {{ widget.type }} -->
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% 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 `<h3>` removed since the wrapper provides it
|
||||||
|
- The `custom-html` widget's inner `<h3>` is removed — the wrapper uses `widget.config.title` or "Custom"
|
||||||
|
- The `<is-land on:visible>` wrappers remain inside the individual widget files — the collapsible wrapper is OUTSIDE `<is-land>`, 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 <ref-of-a-widget-header> # 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. **`<is-land>` + Alpine.js interaction:** The collapsible wrapper's `x-data` is OUTSIDE `<is-land>`. Alpine.js initializes the toggle immediately. The `<is-land on:visible>` 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 `<is-land>` 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 `<h3>` — 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 `<is-land>` + `<h3>`. Now it's inline inside the collapsible wrapper with the `<h3>` removed. The `<is-land>` wrapper is preserved inside for lazy-loading Pagefind.
|
||||||
|
|
||||||
|
**Wait — re-reading the new sidebar template:** The search widget was changed to NOT use `<is-land>` in the inline version. Let me note this: the search widget should keep its `<is-land on:visible>` wrapper inside the collapsible content div. Update the search case to:
|
||||||
|
```nunjucks
|
||||||
|
{% elif widget.type == "search" %}
|
||||||
|
<is-land on:visible>
|
||||||
|
<div class="widget">
|
||||||
|
<div id="sidebar-search"></div>
|
||||||
|
<script>initPagefind("#sidebar-search");</script>
|
||||||
|
</div>
|
||||||
|
</is-land>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Custom HTML widget:** Similarly should keep `<is-land on:visible>` wrapper:
|
||||||
|
```nunjucks
|
||||||
|
{% elif widget.type == "custom-html" %}
|
||||||
|
{% set wConfig = widget.config or {} %}
|
||||||
|
<is-land on:visible>
|
||||||
|
<div class="widget">
|
||||||
|
{% if wConfig.content %}
|
||||||
|
<div class="prose dark:prose-invert prose-sm max-w-none">
|
||||||
|
{{ wConfig.content | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</is-land>
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user