Skip to content

Commit e58414c

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: - Featured repos as FeaturedCards at full width (top of page) - Sticky side-nav with section links (Featured, Active, Available, Archived) - Repos grouped by tier into scrollable sections - Compact list format: name (linked), category tag, one-line description - IntersectionObserver highlights current section in side-nav - Drops the catalog-filter dropdown entirely — spatial navigation replaces it - 2-col featured grid; single-column compact list for everything else
1 parent 4465592 commit e58414c

3 files changed

Lines changed: 141 additions & 82 deletions

File tree

src/design/layout.css

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,29 +68,55 @@
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-index__featured h2 {
85+
margin-block-end: var(--space-4);
86+
}
87+
.work-list {
8788
list-style: none;
89+
padding: 0;
90+
display: flex;
91+
flex-direction: column;
92+
gap: 0;
8893
}
89-
.work-index__section-heading h2 {
90-
font-size: var(--step-0);
91-
color: var(--color-ink-subtle);
94+
.work-list__item {
95+
padding: var(--space-3) 0;
9296
border-block-end: 1px solid var(--color-surface-alt);
93-
padding-block-end: var(--space-2);
97+
}
98+
.work-list__item:first-child {
99+
padding-block-start: 0;
100+
}
101+
.work-list__header {
102+
display: flex;
103+
align-items: baseline;
104+
justify-content: space-between;
105+
gap: var(--space-3);
106+
}
107+
.work-list__name {
108+
font-weight: 600;
109+
text-decoration: none;
110+
color: var(--color-ink);
111+
}
112+
.work-list__name:hover {
113+
color: var(--color-link-hover);
114+
text-decoration: underline;
115+
}
116+
.work-list__desc {
117+
color: var(--color-ink-subtle);
118+
font-size: var(--step--1);
119+
margin-block-start: var(--space-1);
94120
}
95121
.work-detail__related {
96122
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[] }
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+
{ id: 'active', label: 'Active', entries: rest.filter((e) => e.tier === 'active') },
40+
{ id: 'available', label: 'Available', entries: rest.filter((e) => e.tier === 'unreviewed' || e.tier === 'as-is') },
41+
{ id: 'archived', label: 'Archived', entries: rest.filter((e) => e.tier === 'archived') },
42+
].filter((s) => s.entries.length > 0)
43+
44+
// Sort entries within each section by pushedAt descending
45+
for (const section of sections) {
46+
section.entries.sort((a, b) => b.pushedAt.localeCompare(a.pushedAt))
47+
}
48+
49+
const navItems = [
50+
...(featured.length > 0 ? [{ href: '#featured', label: 'Featured' }] : []),
51+
...sections.map((s) => ({ href: `#${s.id}`, label: `${s.label} (${s.entries.length})` })),
52+
]
2553

2654
return (
2755
<Layout title="Work" config={config}>
2856
<h1>Our work</h1>
2957
<p class="work-index__intro">
3058
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{' '}
59+
open-source projects we actively contribute to. See our{' '}
3360
<a href={url('/work/health/', config.basePath)}>stewardship scorecard</a> for
3461
how each repo measures up.
3562
</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>
63+
64+
{featured.length > 0 ? (
65+
<section id="featured" class="work-index__featured" aria-labelledby="featured-heading">
66+
<h2 id="featured-heading">Featured</h2>
67+
<div class="work-index__featured-grid">
68+
{featured.map((entry) => (
69+
<FeaturedCard entry={entry} basePath={config.basePath} />
70+
))}
71+
</div>
72+
</section>
73+
) : null}
74+
75+
<div class="l-sidebar">
76+
<side-nav>
77+
<nav class="side-nav" aria-label="Work sections">
78+
<ul class="side-nav__list">
79+
{navItems.map(({ href, label }) => (
80+
<li class="side-nav__item">
81+
<a class="side-nav__link" href={href}>{label}</a>
6982
</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-
</>
83+
))}
84+
</ul>
85+
</nav>
86+
</side-nav>
87+
88+
<div class="l-stack" data-space="lg">
89+
{sections.map((section) => (
90+
<section id={section.id} aria-labelledby={`${section.id}-heading`}>
91+
<h2 id={`${section.id}-heading`}>{section.label}</h2>
92+
<ul class="work-list">
93+
{section.entries.map((entry) => (
94+
<li class="work-list__item">
95+
<div class="work-list__header">
96+
<a class="work-list__name" href={url(`/work/${entry.name}/`, config.basePath)}>
97+
{entry.name}
98+
</a>
99+
<Tag variant={`category-${entry.category}`}>
100+
{CATEGORY_LABEL[entry.category]}
101+
</Tag>
102+
</div>
103+
{entry.overlay?.summary || entry.description ? (
104+
<p class="work-list__desc">
105+
{entry.overlay?.summary ?? entry.description}
106+
</p>
107+
) : null}
108+
</li>
109+
))}
110+
</ul>
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)