Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ See `docs/decisions/0001-architecture.md` for the architectural decision record.
| Documentation theme | Starlight | 0.42.1 |
| Styling | Tailwind CSS | 4.3.3 |
| Islands | Preact | 10.29.8 |
| Syntax highlighting | Shiki (Astro built-in `<Code>`) | 4.4.3 |
| Linting | oxlint | 1.83.0 |
| Formatting | oxfmt | 0.68.0 |
| TypeScript | 5.9.3 |
Expand Down Expand Up @@ -207,13 +208,18 @@ the offending property, the expected shape, and a remediation hint.
Use `rootPage` to name the page served at `/docs/<project>/` and `exclude`
to drop files from the aggregation — see
[Documentation landing pages](#documentation-landing-pages).
4. Add relationships with `related`, or `supersededBy`/`supersedes` for
4. Add concrete `useCases` (see [Use cases](#use-cases)) — at least one is
expected for every active project. Each entry needs an `audience`
(`developer`, `team-lead`, `architect`, or `contributor`), `title`,
`scenario`, and `outcome`; `code` + `language`, `evidence`, and `docsPage`
are optional.
5. Add relationships with `related`, or `supersededBy`/`supersedes` for
archived projects.
5. Credit upstream work with `acknowledgments` (`name`, `url`, and an optional
6. Credit upstream work with `acknowledgments` (`name`, `url`, and an optional
`description`) when the project is based on, or forked from, another
project — for example ZodSharp credits the original Zod and the
`guinhx/ZodSharp` port it was forked from.
6. Run `just validate` — the manifest schema, catalogue page, project page,
7. Run `just validate` — the manifest schema, catalogue page, project page,
docs aggregation, and release transforms are all regenerated from this one
file.

Expand All @@ -224,6 +230,28 @@ Projects the organisation contributes to but does not own are listed under
section on the catalogue page and link to their own site/repository (e.g.
`https://likec4.dev` for LikeC4) rather than the Purview catalogue.

## Use cases

Each project's `useCases` entries are the site's "concrete evidence": an
audience-tagged example of the tool solving a real problem, showing the code and
stating the outcome. Prose explains intent; a use case is meant to prove it.

- `audience` — `developer`, `team-lead`, `architect`, or `contributor`.
- `title` / `scenario` / `outcome` — the headline, the situation, and what the
reader gets.
- `code` + `language` — a short, accurate snippet. The schema requires
`language` whenever `code` is present.
- `evidence` — a hard fact: a before/after, a count, a generated-output excerpt,
or a measured property (for example ZodSharp's zero-allocation valid path).
- `docsPage` — an optional docs page slug; the card links to
`/docs/<project>/<docsPage>/`. The post-build link crawl fails the build if a
deep link does not resolve, so these cannot rot.

Use cases surface in three places: the home page ("What it looks like in
practice"), each project page ("Use cases"), and the filterable catalogue at
`/use-cases/` (filter by audience, free-text search). Keep snippets short and
point at the documentation for depth rather than duplicating long examples.

## Documentation aggregation

Documentation is owned by each product repository and presented through this
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 75 additions & 0 deletions docs/decisions/0002-concrete-use-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR 0002 — Surface concrete, audience-tagged use cases in the catalogue

- Status: accepted
- Date: 2026-09-29

## Context

The site explains what Purview is and why each project exists, but a reader
arriving without context could not see what the tools actually do. Feedback on
the beta was consistent: it is not obvious what it does depending on the
audience, a few examples/use cases would help, and the audience — even one
already aware of the organisation — still needs concrete evidence.

The project manifest already carried `description`, `origin`, `useWhen`, and
`avoidWhen`, but these are prose. The concrete examples that existed — dozens of
code fences across the aggregated documentation — were only reachable after a
click into the portal, and were written as reference material rather than as
proof.

## Decisions

### 1. Model use cases in the catalogue, not in a separate content store

Every project gains an optional `useCases` array in `src/data/projects.yml`,
validated by the Zod manifest schema. A use case is audience-tagged
(`developer`, `team-lead`, `architect`, `contributor`) and carries a `title`,
`scenario`, `outcome`, optional `code` + `language`, optional `evidence`, and an
optional `docsPage` deep link. Keeping this in the single source of truth means
the catalogue, project page, and use-case index cannot disagree, and the schema
enforces the shape at build time.

### 2. Snippets are short and point at the documentation for depth

Use cases carry a short taste of the code and an `evidence` fact (a before/after,
a count, a generated-output excerpt, or a measured property such as ZodSharp's
zero-allocation valid path). Long examples stay in the aggregated documentation
so there is one copy of them; the card links into `/docs/<project>/<docsPage>/`.

### 3. Deep links are validated by the link crawl

`docsPage` slugs are rendered into `/docs/<project>/<docsPage>/` links. The
post-build crawl (`just check-links`) fails the build when a link does not
resolve, so a renamed documentation page cannot leave a dead link behind. A unit
test additionally rejects a `docsPage` on a project that publishes no
documentation.

### 4. Render server-side, enhance with one small island

Use cases are rendered as static HTML on the home page, project pages, and
`/use-cases/`. A single Preact island (`UseCaseFilter`) toggles visibility on
the index page only; the page is fully usable without JavaScript, consistent
with the existing catalogue and release filters.

### 5. Highlight code at build time with Shiki

Code samples are highlighted at build time by Astro's built-in `<Code>`
component, which wraps Shiki. This keeps the marketing pages consistent with the
documentation (Starlight's Expressive Code uses Night Owl) and ships no client
JavaScript. The theme is passed as an imported object (`@shikijs/themes/night-owl`)
rather than a Shiki theme name, because Astro's bundled Shiki theme registry
resolves to an empty map in this build and a string name — even the default —
throws "not included in this bundle". One CSS rule makes the frame's
brand-tinted background show through instead of the theme background, so the
highlighted samples and the un-highlighted install panel share the same surface.

## Consequences

- Adding a project now includes writing at least one concrete use case; the
manifest test suite asserts coverage across projects.
- The audience vocabulary is deliberately small and lives in the schema; adding
an audience is a schema change, so it is a considered decision rather than a
free-text label.
- Use cases are hand-authored prose-plus-code in YAML. This is acceptable for the
current catalogue size; if it grows substantially, snippets could move to files
referenced by path without changing the rendered shape.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Shared engineering documentation and architecture decisions for Purview Developm
## Architecture decisions

- [ADR 0001: Architecture](decisions/0001-architecture.md)
- [ADR 0002: Concrete use cases](decisions/0002-concrete-use-cases.md)
1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@astrojs/sitemap": "3.7.4",
"@astrojs/starlight": "0.42.3",
"@astrojs/starlight-tailwind": "5.0.0",
"@shikijs/themes": "4.4.3",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "1.4.2",
"@types/semver": "^7.8.0",
Expand Down
39 changes: 39 additions & 0 deletions src/src/components/CodeSample.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
import { Code } from 'astro:components';
import nightOwl from '@shikijs/themes/night-owl';
import type { CodeLanguage } from 'astro';

interface Props {
code: string;
/** Language label shown in the frame header (e.g. "csharp", "yaml"). */
language?: string;
}

const { code, language = 'plaintext' } = Astro.props;
// The manifest stores a free-form language string; Astro's <Code> falls back to
// `plaintext` for anything it does not recognise, so the narrowing is safe.
const lang = language as CodeLanguage;
// The theme is passed as an imported object rather than a Shiki theme name.
// Astro's bundled Shiki theme registry resolves to an empty map in this build,
// so a string name (even the default) throws "not included in this bundle"; an
// object bypasses the registry lookup. Night Owl matches the documentation
// pages, which use Starlight's Night Owl dark theme.
const theme = nightOwl;
// `wrap` is on so a long line soft-wraps inside the frame instead of producing a
// horizontal scrollbar. The cards sit in multi-column grids, where a scrollbar
// would hide content the reader is meant to see at a glance.
---

<div class="pv-code-frame">
<div class="pv-code-frame-header">
<span class="flex shrink-0 gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full bg-[#ff5f56]"></span>
<span class="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]"></span>
<span class="h-2.5 w-2.5 rounded-full bg-[#27c93f]"></span>
</span>
<span class="min-w-0 flex-1 truncate text-center font-mono text-xs text-white/50">
{language}
</span>
</div>
<Code code={code} lang={lang} theme={theme} class="pv-code-frame-body" wrap={true} />
</div>
1 change: 1 addition & 0 deletions src/src/components/SiteNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type NavItem = {
export const NAV_ITEMS: readonly NavItem[] = [
{ label: 'Home', href: '/', icon: 'home' },
{ label: 'Projects', href: '/projects/' },
{ label: 'Use cases', href: '/use-cases/' },
{ label: 'Documentation', href: '/docs/' },
{ label: 'Releases', href: '/releases/' },
{ label: 'About', href: '/about/' },
Expand Down
54 changes: 54 additions & 0 deletions src/src/components/UseCaseCard.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
import CodeSample from '~/components/CodeSample.astro';
import { AUDIENCE_LABELS, type ProjectUseCase } from '~/lib/manifest/schema';

interface Props {
useCase: ProjectUseCase;
projectName: string;
projectId: string;
/** Base docs URL for the owning project (`/docs/<id>/`), or null when the project has no docs. */
docsBase: string | null;
/** Hide the owning project chip when the card is already scoped to that project. */
showProject?: boolean;
}

const { useCase, projectName, projectId, docsBase, showProject = true } = Astro.props;
const audienceLabel = AUDIENCE_LABELS[useCase.audience];
const docsLink =
docsBase && useCase.docsPage ? `${docsBase}${useCase.docsPage}/` : (docsBase ?? null);
---

<article
class="pv-card flex h-full min-w-0 flex-col gap-4 p-5"
data-use-case
data-audience={useCase.audience}
>
<div class="flex flex-wrap items-center gap-2">
<span class="pv-chip pv-audience-chip">{audienceLabel}</span>
{showProject && <span class="pv-chip">{projectName}</span>}
</div>

<h3 class="text-lg font-semibold leading-snug">{useCase.title}</h3>

<p class="text-sm leading-relaxed text-muted">{useCase.scenario}</p>

{useCase.code && <CodeSample code={useCase.code} language={useCase.language} />}

<p class="text-sm leading-relaxed text-muted">
<span class="font-medium text-default">What you get: </span>{useCase.outcome}
</p>

{
useCase.evidence && (
<p class="pv-evidence text-sm leading-relaxed">{useCase.evidence}</p>
)
}

{
docsLink && (
<a href={docsLink} class="pv-link mt-auto text-sm" data-use-case-project={projectId}>
Read the guide →
</a>
)
}
</article>
94 changes: 94 additions & 0 deletions src/src/components/islands/UseCaseFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'preact/hooks';

interface AudienceOption {
value: string;
label: string;
}

interface Props {
audiences: AudienceOption[];
}

/**
* Filters the use-case cards rendered on `/use-cases/`. Cards are
* server-rendered and carry `data-audience` / `data-usecase-search`; this island
* only toggles their `hidden` attribute, so the page stays fully usable without
* JavaScript.
*/
export default function UseCaseFilter({ audiences }: Props) {
const [audience, setAudience] = useState('all');
const [search, setSearch] = useState('');
const [visibleCount, setVisibleCount] = useState(0);

useEffect(() => {
const items = Array.from(document.querySelectorAll<HTMLElement>('[data-usecase-id]'));
const query = search.trim().toLowerCase();
let visible = 0;
for (const item of items) {
const matchesAudience =
audience === 'all' || (item.dataset.usecaseAudience ?? '') === audience;
const matchesSearch = query === '' || (item.dataset.usecaseSearch ?? '').includes(query);
const show = matchesAudience && matchesSearch;
item.hidden = !show;
if (show) {
visible += 1;
}
}
setVisibleCount(visible);
}, [audience, search]);

const selectClasses =
'rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground';

return (
<div class="flex flex-wrap items-center gap-3">
<label
class="border-border bg-surface flex min-w-56 flex-1 items-center gap-2 rounded-md border px-3 py-2"
htmlFor="use-case-search"
>
<span class="sr-only">Search use cases</span>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<path stroke-linecap="round" d="m20 20-3.5-3.5" />
</svg>
<input
id="use-case-search"
type="search"
value={search}
onInput={(event) => setSearch((event.target as HTMLInputElement).value)}
placeholder="Search use cases…"
class="w-full bg-transparent text-sm outline-none"
/>
</label>

<label class="sr-only" htmlFor="filter-audience">
Filter by audience
</label>
<select
id="filter-audience"
class={selectClasses}
value={audience}
onChange={(event) => setAudience((event.target as HTMLSelectElement).value)}
>
<option value="all">All audiences</option>
{audiences.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</select>

<p class="text-muted text-sm" aria-live="polite">
{visibleCount} use case{visibleCount === 1 ? '' : 's'}
</p>
</div>
);
}
Loading
Loading