🧠 Context
Disclaimer: The proposed approach may or may not work exactly as intended. If you find errors with the logic, try to work around them or flag it to Jacc.
The /projects gallery renders every project card with no way to narrow them. This ticket adds a filter toolbar below the page header that lets visitors filter the grid by domain and year, both multi-select. All cards stay in the DOM — filtering just sets a marker attribute that CSS hides — so nothing is removed, and a separate search feature can compose with it without sharing any code (see step 4).
Each card already carries the filter contract on its <article>: data-domain and data-year (plus others used by search). Note a project can have more than one domain, so data-domain is a comma-joined list (e.g. "web, cybersecurity"). This ticket only reads those attributes; it must not change them.
Filtering semantics:
- Within a facet: OR. Selecting
Web and Mobile shows projects in either.
- Across facets: AND. Selecting
Web + 2024 shows web projects from 2024.
- Nothing selected = show everything (that's also the cleared state).
The filter toolbar lives in the header's toolbar row. A search box is added to this same toolbar later, and the two arrange responsively: on desktop, filters on the left and search on the right (same row); on mobile they stack with the search bar on top and the filters below. So build the filter controls as the left/main content of that row, don't assume you own the whole width, and expect to sit below the search bar when stacked on mobile.
Mobile chips = horizontal scroll. Stacking every domain + year pill vertically eats too much screen height on mobile. Instead, lay each pill group out as a single horizontal-scrolling row (e.g. flex + overflow-x-auto, no wrap) so it stays one line, with a clear affordance that it scrolls sideways — a fade-out edge, a peeking partial chip, and/or a chevron — so it's obvious there's more off-screen. On desktop the pills can just wrap normally.
Checkout the screenshot of an example mockup below for visual reference (not a strict guideline, just a starting point). Note: I'm not the best at UI design, so if you think a different layout/design might look better, feel free to play around with options and share for feedback!
Desktop:

Mobile:

Files you'll touch:
src/pages/projects.astro (the FILTER BAR insertion point + a small client script)
Don't touch:
- The
data-* attributes on the card <article> (in Card.astro) — read-only contract; don't modify Card.astro.
- The
#project-grid structure or the card markup — just toggle a class on grid items.
- The header CTA button row above the toolbar.
🛠️ Implementation Plan
Note: Any code snippets are starting points and may not work immediately. Adjust them as needed.
-
Compute the facet options in frontmatter. From the already-loaded projects, derive the set of domains present and the set of years present (years sorted descending). Each project's data.domains is an array, so flatten across all projects for the domain set. Readable domain labels come from the shared DOMAIN_LABELS map in src/lib/domains.ts (kebab-case enum value → display label) — import it, don't redefine it (the detail page uses the same map). Add under the existing frontmatter in src/pages/projects.astro:
---
import { DOMAIN_LABELS } from '../lib/domains';
const domains = [...new Set(projects.flatMap((p) => p.data.domains))];
const years = [...new Set(projects.map((p) => p.data.year))].sort(
(a, b) => b - a,
);
---
Deriving from the data avoids rendering pills that match zero projects.
-
Render the filter toolbar at the FILTER BAR insertion point (replace the comment). Two pill groups — domains and years — as toggle buttons (aria-pressed), each tagged with the value it filters on. Render them by mapping over the domains / years arrays from step 1 (the value below is the loop variable — it only exists inside the .map()). Keep the pills in a container that leaves room for a search box to be added on the right later. Example shape (token-styled, with an active state):
{
domains.map((value) => (
<button
type="button"
data-filter-domain={value}
aria-pressed="false"
class="border-line aria-pressed:bg-accent aria-pressed:text-accent-contrast aria-pressed:border-accent rounded-full border px-3 py-1 text-sm"
>
{DOMAIN_LABELS[value]}
</button>
))
}
Adjust styling as needed. Year pills are the same idea — map over years with data-filter-year={year} and {year} as the label. Group each set with a small label ("Domain", "Year") for clarity/accessibility.
-
How cards are hidden/shown. Hide a card by setting a marker attribute on its grid <li>, with a CSS rule that hides any <li> carrying it — don't toggle hidden directly (that's one slot the filter and a future search would fight over). This feature owns data-filter-hide. A search feature will be added to this toolbar later and will own a second marker, data-search-hide — so write the rule below to hide an <li> that has either marker now (the data-search-hide half is harmless until search ships, and means search will just work without touching your code). A card shows only when it has neither marker — passing both filter and search — with no shared JS. Put the rule in a <style> in the template part of projects.astro (bottom of the file; default scoping is fine).
#project-grid li[data-filter-hide],
#project-grid li[data-search-hide] {
display: none;
}
-
Client script. Add a <script> that keeps the selected domains and years (two Sets), toggled by aria-pressed on click. On any change, for each card <article> read data-domain / data-year and set/remove data-filter-hide on its closest('li'):
- A card has multiple domains: split
data-domain on , into the card's domain list.
- Filter predicate:
(selectedDomains.size === 0 || cardDomains.some((d) => selectedDomains.has(d))) && (selectedYears.size === 0 || selectedYears.has(String(year))) — i.e. a card matches the domain facet if any of its domains is selected.
The marker means "hide me", so the polarity is inverted: add data-filter-hide to a card's <li> when it does NOT match the filters (to hide it), and remove data-filter-hide when it DOES match (to show it). Touch only data-filter-hide — never hidden, never the search marker.
-
Empty-state. Add a hidden element after the grid (e.g. "No projects match.") and toggle it by counting genuinely-visible items with the union selector — written so it's already correct once search exists: show it when grid.querySelectorAll('li:not([data-filter-hide]):not([data-search-hide])').length === 0. (The search feature will reuse this same empty-state and selector later — you're establishing both here.)
-
Verify in pnpm dev: selecting multiple domains unions them; adding a year narrows within those; clearing all pills shows everything; the empty-state appears only when nothing matches; cards are only hidden via data-filter-hide (still in the DOM); Card.astro and the data-* filter contract are untouched.
✅ Acceptance Criteria
🧠 Context
Disclaimer: The proposed approach may or may not work exactly as intended. If you find errors with the logic, try to work around them or flag it to Jacc.
The
/projectsgallery renders every project card with no way to narrow them. This ticket adds a filter toolbar below the page header that lets visitors filter the grid by domain and year, both multi-select. All cards stay in the DOM — filtering just sets a marker attribute that CSS hides — so nothing is removed, and a separate search feature can compose with it without sharing any code (see step 4).Each card already carries the filter contract on its
<article>:data-domainanddata-year(plus others used by search). Note a project can have more than one domain, sodata-domainis a comma-joined list (e.g."web, cybersecurity"). This ticket only reads those attributes; it must not change them.Filtering semantics:
WebandMobileshows projects in either.Web+2024shows web projects from 2024.The filter toolbar lives in the header's toolbar row. A search box is added to this same toolbar later, and the two arrange responsively: on desktop, filters on the left and search on the right (same row); on mobile they stack with the search bar on top and the filters below. So build the filter controls as the left/main content of that row, don't assume you own the whole width, and expect to sit below the search bar when stacked on mobile.
Mobile chips = horizontal scroll. Stacking every domain + year pill vertically eats too much screen height on mobile. Instead, lay each pill group out as a single horizontal-scrolling row (e.g.
flex+overflow-x-auto, no wrap) so it stays one line, with a clear affordance that it scrolls sideways — a fade-out edge, a peeking partial chip, and/or a chevron — so it's obvious there's more off-screen. On desktop the pills can just wrap normally.Checkout the screenshot of an example mockup below for visual reference (not a strict guideline, just a starting point). Note: I'm not the best at UI design, so if you think a different layout/design might look better, feel free to play around with options and share for feedback!
Desktop:

Mobile:

Files you'll touch:
src/pages/projects.astro(theFILTER BAR insertion point+ a small client script)Don't touch:
data-*attributes on the card<article>(inCard.astro) — read-only contract; don't modifyCard.astro.#project-gridstructure or the card markup — just toggle a class on grid items.🛠️ Implementation Plan
Note: Any code snippets are starting points and may not work immediately. Adjust them as needed.
Compute the facet options in frontmatter. From the already-loaded
projects, derive the set of domains present and the set of years present (years sorted descending). Each project'sdata.domainsis an array, so flatten across all projects for the domain set. Readable domain labels come from the sharedDOMAIN_LABELSmap insrc/lib/domains.ts(kebab-case enum value → display label) — import it, don't redefine it (the detail page uses the same map). Add under the existing frontmatter insrc/pages/projects.astro:Deriving from the data avoids rendering pills that match zero projects.
Render the filter toolbar at the
FILTER BAR insertion point(replace the comment). Two pill groups — domains and years — as toggle buttons (aria-pressed), each tagged with the value it filters on. Render them by mapping over thedomains/yearsarrays from step 1 (thevaluebelow is the loop variable — it only exists inside the.map()). Keep the pills in a container that leaves room for a search box to be added on the right later. Example shape (token-styled, with an active state):Adjust styling as needed. Year pills are the same idea — map over
yearswithdata-filter-year={year}and{year}as the label. Group each set with a small label ("Domain", "Year") for clarity/accessibility.How cards are hidden/shown. Hide a card by setting a marker attribute on its grid
<li>, with a CSS rule that hides any<li>carrying it — don't togglehiddendirectly (that's one slot the filter and a future search would fight over). This feature ownsdata-filter-hide. A search feature will be added to this toolbar later and will own a second marker,data-search-hide— so write the rule below to hide an<li>that has either marker now (thedata-search-hidehalf is harmless until search ships, and means search will just work without touching your code). A card shows only when it has neither marker — passing both filter and search — with no shared JS. Put the rule in a<style>in the template part ofprojects.astro(bottom of the file; default scoping is fine).Client script. Add a
<script>that keeps the selected domains and years (twoSets), toggled byaria-pressedon click. On any change, for each card<article>readdata-domain/data-yearand set/removedata-filter-hideon itsclosest('li'):data-domainon,into the card's domain list.(selectedDomains.size === 0 || cardDomains.some((d) => selectedDomains.has(d))) && (selectedYears.size === 0 || selectedYears.has(String(year)))— i.e. a card matches the domain facet if any of its domains is selected.The marker means "hide me", so the polarity is inverted: add
data-filter-hideto a card's<li>when it does NOT match the filters (to hide it), and removedata-filter-hidewhen it DOES match (to show it). Touch onlydata-filter-hide— neverhidden, never the search marker.Empty-state. Add a hidden element after the grid (e.g. "No projects match.") and toggle it by counting genuinely-visible items with the union selector — written so it's already correct once search exists: show it when
grid.querySelectorAll('li:not([data-filter-hide]):not([data-search-hide])').length === 0. (The search feature will reuse this same empty-state and selector later — you're establishing both here.)Verify in
pnpm dev: selecting multiple domains unions them; adding a year narrows within those; clearing all pills shows everything; the empty-state appears only when nothing matches; cards are only hidden viadata-filter-hide(still in the DOM);Card.astroand thedata-*filter contract are untouched.✅ Acceptance Criteria
data-filter-hide(CSS unions it withdata-search-hide) — all cards stay in the DOM;Card.astroand thedata-*filter contract are unchanged.data-*-hidemarker + CSS union convention).pnpm format:check,pnpm check, andpnpm buildall pass.