Skip to content

Commit a909dfb

Browse files
committed
feat(work): side-nav layout with compact list sections
Replace the card grid + dropdown filters with a side-nav + sectioned compact list approach: - Sticky side-nav with section links (Featured, Active, Available, Archived) - Featured section uses FeaturedCards inside the right panel (not above it) - Non-featured repos in compact list: name, category tag, description - List items have hover accent, padding, and clear visual separation - Tags sit inline with flex-shrink: 0 to prevent compression - Drops catalog-filter entirely — spatial navigation replaces it - IntersectionObserver highlights current section as you scroll
1 parent 4465592 commit a909dfb

3 files changed

Lines changed: 145 additions & 82 deletions

File tree

src/design/layout.css

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,29 +68,59 @@
6868
}
6969
.home-featured__grid,
7070
.home-stats__grid,
71-
.work-index__list {
71+
.work-index__featured-grid {
7272
display: grid;
7373
gap: var(--space-5);
7474
container-type: inline-size;
7575
}
7676
@container (min-width: 48rem) {
7777
.home-featured__grid { grid-template-columns: repeat(2, 1fr); }
7878
.home-stats__grid { grid-template-columns: repeat(3, 1fr); }
79-
.work-index__list { grid-template-columns: repeat(2, 1fr); }
79+
.work-index__featured-grid { grid-template-columns: repeat(2, 1fr); }
8080
}
8181
@container (min-width: 72rem) {
8282
.home-featured__grid { grid-template-columns: repeat(4, 1fr); }
83-
.work-index__list { grid-template-columns: repeat(3, 1fr); }
8483
}
85-
.work-index__section-heading {
86-
grid-column: 1 / -1;
84+
.work-list {
8785
list-style: none;
86+
padding: 0;
87+
display: flex;
88+
flex-direction: column;
89+
gap: 0;
8890
}
89-
.work-index__section-heading h2 {
90-
font-size: var(--step-0);
91-
color: var(--color-ink-subtle);
91+
.work-list__item {
92+
padding: var(--space-4) var(--space-4);
9293
border-block-end: 1px solid var(--color-surface-alt);
93-
padding-block-end: var(--space-2);
94+
border-inline-start: 3px solid transparent;
95+
}
96+
.work-list__item:last-child {
97+
border-block-end: none;
98+
}
99+
.work-list__item:hover {
100+
border-inline-start-color: var(--color-accent);
101+
background: var(--color-surface-highlight);
102+
}
103+
.work-list__header {
104+
display: flex;
105+
align-items: center;
106+
gap: var(--space-3);
107+
}
108+
.work-list__header .tag {
109+
flex-shrink: 0;
110+
}
111+
.work-list__name {
112+
font-weight: 600;
113+
text-decoration: none;
114+
color: var(--color-ink);
115+
}
116+
.work-list__name:hover {
117+
color: var(--color-link-hover);
118+
text-decoration: underline;
119+
}
120+
.work-list__desc {
121+
color: var(--color-ink-subtle);
122+
font-size: var(--step--1);
123+
margin-block-start: var(--space-2);
94124
}
95125
.work-detail__related {
96126
margin-block-start: var(--space-6);

src/pages/work/index.tsx

Lines changed: 83 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import { Layout } from '../../design/common/layout'
2-
import { RepoCard } from '../../design/components/repo-card'
3-
import { Select } from '../../design/components/select'
2+
import { FeaturedCard } from '../../design/components/featured-card'
3+
import { Tag } from '../../design/components/tag'
44
import type { Catalog, CatalogEntry } from '../../catalog/types'
55
import { url } from '../../build/config'
66
import type { SiteConfig } from '../../build/config'
77

88
declare module 'hono/jsx' {
99
namespace JSX {
1010
interface IntrinsicElements {
11-
'catalog-filter': { children?: any }
11+
'side-nav': { children?: any }
1212
}
1313
}
1414
}
1515

16+
const CATEGORY_LABEL: Record<CatalogEntry['category'], string> = {
17+
product: 'Product',
18+
tool: 'Tool',
19+
workshop: 'Workshop',
20+
prototype: 'Prototype',
21+
fork: 'Fork',
22+
uncategorized: 'Uncategorized',
23+
}
24+
25+
type Section = { id: string; label: string; entries: CatalogEntry[]; featured?: boolean }
26+
1627
export function WorkIndex({
1728
catalog,
1829
config,
@@ -21,72 +32,86 @@ export function WorkIndex({
2132
config: SiteConfig
2233
}) {
2334
const visible = catalog.filter((e) => !e.hidden)
24-
const sorted = [...visible].sort(defaultSort)
35+
const featured = visible.filter((e) => e.featured)
36+
const rest = visible.filter((e) => !e.featured)
37+
38+
const sections: Section[] = [
39+
...(featured.length > 0 ? [{ id: 'featured', label: 'Featured', entries: featured, featured: true }] : []),
40+
{ id: 'active', label: 'Active', entries: rest.filter((e) => e.tier === 'active') },
41+
{ id: 'available', label: 'Available', entries: rest.filter((e) => e.tier === 'unreviewed' || e.tier === 'as-is') },
42+
{ id: 'archived', label: 'Archived', entries: rest.filter((e) => e.tier === 'archived') },
43+
].filter((s) => s.entries.length > 0)
44+
45+
// Sort non-featured entries within each section by pushedAt descending
46+
for (const section of sections) {
47+
if (!section.featured) {
48+
section.entries.sort((a, b) => b.pushedAt.localeCompare(a.pushedAt))
49+
}
50+
}
51+
52+
const navItems = sections.map((s) => ({
53+
href: `#${s.id}`,
54+
label: s.featured ? s.label : `${s.label} (${s.entries.length})`,
55+
}))
2556

2657
return (
2758
<Layout title="Work" config={config}>
2859
<h1>Our work</h1>
2960
<p class="work-index__intro">
3061
Flexion's public portfolio — tools we've built, prototypes we've shipped, and
31-
open-source projects we actively contribute to. Use the filters to explore by
32-
tier or category. See our{' '}
62+
open-source projects we actively contribute to. See our{' '}
3363
<a href={url('/work/health/', config.basePath)}>stewardship scorecard</a> for
3464
how each repo measures up.
3565
</p>
36-
<catalog-filter>
37-
<form class="catalog-filter">
38-
<fieldset>
39-
<legend>Filter</legend>
40-
<Select name="tier" label="Tier">
41-
<option value="">All</option>
42-
<option value="active">Active</option>
43-
<option value="unreviewed">Unreviewed</option>
44-
<option value="as-is">As-is</option>
45-
<option value="archived">Archived</option>
46-
</Select>
47-
<Select name="category" label="Category">
48-
<option value="">All</option>
49-
<option value="product">Product</option>
50-
<option value="tool">Tool</option>
51-
<option value="workshop">Workshop</option>
52-
<option value="prototype">Prototype</option>
53-
<option value="fork">Fork</option>
54-
<option value="uncategorized">Uncategorized</option>
55-
</Select>
56-
</fieldset>
57-
</form>
58-
<ul class="work-index__list">
59-
{sorted.map((entry, i) => (
60-
<>
61-
{entry.featured && i === 0 ? (
62-
<li class="work-index__section-heading" data-featured="true" role="presentation" aria-hidden="true">
63-
<h2>Featured</h2>
64-
</li>
65-
) : null}
66-
{!entry.featured && (i === 0 || sorted[i - 1].featured) ? (
67-
<li class="work-index__section-heading" role="presentation" aria-hidden="true">
68-
<h2>All projects</h2>
66+
67+
<div class="l-sidebar">
68+
<side-nav>
69+
<nav class="side-nav" aria-label="Work sections">
70+
<ul class="side-nav__list">
71+
{navItems.map(({ href, label }) => (
72+
<li class="side-nav__item">
73+
<a class="side-nav__link" href={href}>{label}</a>
6974
</li>
70-
) : null}
71-
<li data-tier={entry.tier} data-category={entry.category} data-featured={entry.featured ? 'true' : undefined}>
72-
<RepoCard entry={entry} basePath={config.basePath} />
73-
</li>
74-
</>
75+
))}
76+
</ul>
77+
</nav>
78+
</side-nav>
79+
80+
<div class="l-stack" data-space="xl">
81+
{sections.map((section) => (
82+
<section id={section.id} aria-labelledby={`${section.id}-heading`}>
83+
<h2 id={`${section.id}-heading`}>{section.label}</h2>
84+
{section.featured ? (
85+
<div class="work-index__featured-grid">
86+
{section.entries.map((entry) => (
87+
<FeaturedCard entry={entry} basePath={config.basePath} />
88+
))}
89+
</div>
90+
) : (
91+
<ul class="work-list">
92+
{section.entries.map((entry) => (
93+
<li class="work-list__item">
94+
<div class="work-list__header">
95+
<a class="work-list__name" href={url(`/work/${entry.name}/`, config.basePath)}>
96+
{entry.name}
97+
</a>
98+
<Tag variant={`category-${entry.category}`}>
99+
{CATEGORY_LABEL[entry.category]}
100+
</Tag>
101+
</div>
102+
{entry.overlay?.summary || entry.description ? (
103+
<p class="work-list__desc">
104+
{entry.overlay?.summary ?? entry.description}
105+
</p>
106+
) : null}
107+
</li>
108+
))}
109+
</ul>
110+
)}
111+
</section>
75112
))}
76-
</ul>
77-
</catalog-filter>
113+
</div>
114+
</div>
78115
</Layout>
79116
)
80117
}
81-
82-
function defaultSort(a: CatalogEntry, b: CatalogEntry): number {
83-
if (a.featured !== b.featured) return a.featured ? -1 : 1
84-
const tierRank: Record<CatalogEntry['tier'], number> = {
85-
active: 0,
86-
unreviewed: 1,
87-
'as-is': 2,
88-
archived: 3,
89-
}
90-
if (tierRank[a.tier] !== tierRank[b.tier]) return tierRank[a.tier] - tierRank[b.tier]
91-
return b.pushedAt.localeCompare(a.pushedAt)
92-
}

tests/views/work-index.test.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { fixtureCatalog } from '../fixtures/catalog'
66
const config = { basePath: '/', buildTime: '2026-04-27T12:00:00Z' }
77

88
describe('WorkIndex', () => {
9-
test('renders a row for every non-hidden repo', async () => {
9+
test('renders featured repos in a featured section', async () => {
1010
const html = await renderToHtml(
1111
<WorkIndex catalog={fixtureCatalog} config={config} />,
1212
)
13-
for (const entry of fixtureCatalog) {
14-
expect(html).toContain(entry.name)
15-
}
13+
expect(html).toContain('id="featured"')
14+
expect(html).toContain('featured-card')
15+
expect(html).toContain('messaging')
16+
expect(html).toContain('forms')
17+
expect(html).toContain('document-extractor')
1618
})
1719

1820
test('omits hidden repos', async () => {
@@ -22,26 +24,32 @@ describe('WorkIndex', () => {
2224
const html = await renderToHtml(
2325
<WorkIndex catalog={catalog} config={config} />,
2426
)
25-
expect(html).not.toContain('messaging')
27+
expect(html).not.toContain('href="/work/messaging/"')
2628
})
2729

28-
test('wraps the list in a <catalog-filter> web component', async () => {
30+
test('renders a side-nav with section links', async () => {
2931
const html = await renderToHtml(
3032
<WorkIndex catalog={fixtureCatalog} config={config} />,
3133
)
32-
expect(html).toContain('<catalog-filter')
34+
expect(html).toContain('<side-nav')
35+
expect(html).toContain('class="side-nav"')
3336
})
3437

35-
test('applies the default sort: featured first, then active, then by pushedAt desc', async () => {
38+
test('groups non-featured repos into tier sections', async () => {
3639
const html = await renderToHtml(
3740
<WorkIndex catalog={fixtureCatalog} config={config} />,
3841
)
39-
const order = ['messaging', 'forms', 'document-extractor']
40-
let last = -1
41-
for (const name of order) {
42-
const idx = html.indexOf(`href="/work/${name}/"`)
43-
expect(idx).toBeGreaterThan(last)
44-
last = idx
45-
}
42+
expect(html).toContain('id="available"')
43+
expect(html).toContain('id="archived"')
44+
expect(html).toContain('fork-of-thing')
45+
expect(html).toContain('archived-thing')
46+
})
47+
48+
test('renders compact list items with name and category', async () => {
49+
const html = await renderToHtml(
50+
<WorkIndex catalog={fixtureCatalog} config={config} />,
51+
)
52+
expect(html).toContain('class="work-list__name"')
53+
expect(html).toContain('class="work-list__desc"')
4654
})
4755
})

0 commit comments

Comments
 (0)