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
4 changes: 4 additions & 0 deletions .github/workflows/validate-datasets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
- name: Validate datasets offline
run: npm run validate-datasets:offline

- name: Check reviewed app source snapshots
if: github.event_name == 'schedule'
run: npx vitest run src/content/apps.test.ts

- name: Validate provider contracts
if: github.event_name != 'pull_request'
run: npm run validate-providers
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Each dataset includes searchable metadata, authoritative source links, practical
access instructions, a copyable Python example, and a small project for testing
a useful product signal.

The Apps directory showcases six data-powered projects. Each app page cites the
external sources behind it, links to matching dataset guides where available,
and links to its hosted experience or archive and source code. The projects run
outside this static site.

The catalog optimizes for beginner clarity, contribution simplicity, and dataset
quality — not catalog size.

Expand Down Expand Up @@ -59,19 +64,23 @@ interaction rules, and checksums.
- Azure `#5858C8` links and light-surface focus; amber `#FF9940` dark-surface focus
- Roboto typography, 10px controls, 16px cards, and 44px minimum interactive targets

Data renders only its local header (Datasets, Build Paths, Contribute), with
Data renders only its local header (Datasets, Apps, Build Paths, Contribute), with
sticky offsets measured from its actual height. The shared Foundation footer
remains. Run `npm run check:design` when updating the vendored contract.

## Editing content

Content has two explicit sources of truth:
Content has three explicit sources of truth:

- Shared application, navigation, accessibility, and SEO copy lives in
[`src/content/site-copy.ts`](src/content/site-copy.ts). Non-description text
uses Chicago Title Case; descriptions and other body copy stay in sentence case.
- Dataset descriptions and beginner guides live with their metadata in the
corresponding [`data/datasets/*.yaml`](data/datasets) file.
- App descriptions, availability, outbound links, and grouped source citations
live in [`src/content/apps`](src/content/apps). Each reviewed snapshot records
its product revision and review date. Review these at least every 90 days and
when a project changes; a listed source is not a claim that its feed is live.

Each dataset selects one broad catalog theme. Domain and task tags are
normalized through [`data/vocabulary.yaml`](data/vocabulary.yaml): aliases
Expand Down
8 changes: 8 additions & 0 deletions e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ test("collection, theme, and contribute pages pass accessibility checks", async
await expectNoAccessibilityViolations(page);
}
});

test("Apps listing, long source map, and archive page pass accessibility checks", async ({ page }) => {
for (const path of ["/apps", "/apps/travelcanary", "/apps/stackingsats"]) {
await page.goto(path);
await expect(page.locator("main h1")).toBeVisible();
await expectNoAccessibilityViolations(page);
}
});
110 changes: 110 additions & 0 deletions e2e/apps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { existsSync, readdirSync } from "node:fs";
import { expect, test } from "@playwright/test";

const appNames = [
["titanskies", "TitanSkies"],
["hyperoptions", "HyperOptions"],
["travelcanary", "TravelCanary"],
["househunter", "HouseHunter"],
["rockyroad", "RockyRoad"],
["stackingsats", "StackingSats"],
] as const;

test("the seven Apps routes are exported and show the right project", async ({ page, request }) => {
expect(existsSync("out/apps.html")).toBe(true);
const listing = await request.get("/apps");
expect(listing.ok()).toBe(true);
expect(readdirSync("out/apps").filter((file) => file.endsWith(".html")).sort()).toEqual(
appNames.map(([slug]) => `${slug}.html`).sort(),
);
expect((await request.get("/apps/not-a-project")).status()).toBe(404);
await page.goto("/apps");
await expect(page.getByRole("heading", { level: 1, name: "Apps" })).toBeVisible();
const cards = page.getByRole("article");
await expect(cards).toHaveCount(6);
for (const [index, [slug, title]] of appNames.entries()) {
await expect(cards.nth(index).getByRole("heading", { level: 2, name: title })).toBeVisible();
await expect(cards.nth(index).getByRole("link", { name: title, exact: true })).toHaveAttribute("href", `/apps/${slug}`);
}
for (const [slug, title] of appNames) {
expect(existsSync(`out/apps/${slug}.html`), slug).toBe(true);
const response = await request.get(`/apps/${slug}`);
expect(response.ok(), slug).toBe(true);
await page.goto(`/apps/${slug}`);
await expect(page.getByRole("heading", { level: 1, name: title })).toBeVisible();
await expect(page.getByRole("navigation", { name: "Data navigation" }).getByRole("link", { name: "Apps", exact: true })).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("heading", { level: 2, name: "Data Sources" })).toBeVisible();
await expect(page.getByRole("link", { name: "Apps", exact: true }).last()).toHaveAttribute("href", "/apps");
await expect(page.getByRole("link", { name: /Official Source/ }).first()).toBeVisible();
await expect(page.getByText("Role:", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Availability:", { exact: true }).first()).toBeVisible();
const guidePaths = await page.locator('main a[href^="/datasets/"]').evaluateAll((links) =>
[...new Set(links.map((link) => link.getAttribute("href")).filter((href): href is string => Boolean(href)))],
);
for (const path of guidePaths) expect((await request.get(path)).ok(), `${slug}: ${path}`).toBe(true);
}
});

test("Apps is a primary header destination on listing and details", async ({ page }) => {
for (const path of ["/apps", "/apps/travelcanary", "/apps/stackingsats/"]) {
await page.goto(path);
const nav = page.getByRole("navigation", { name: "Data navigation" });
await expect(nav.locator("a")).toHaveText(["Data", "Datasets", "Apps", "Build Paths", "Contribute"]);
await expect(nav.getByRole("link", { name: "Apps", exact: true })).toHaveAttribute("aria-current", "page");
await expect(nav.getByRole("link", { name: "Datasets", exact: true })).not.toHaveAttribute("aria-current");
}
});

test("cards and details expose only the intended external actions", async ({ page }) => {
await page.goto("/apps");
const cards = page.getByRole("article");
const expected = [
["Live App", "https://www.titanskies.com/", "https://github.com/hypertrial/titanskies"],
[undefined, undefined, "https://github.com/hypertrial/hyperoptions"],
["Live App", "https://travelcanary.org/", "https://github.com/hypertrial/travelcanary"],
[undefined, undefined, "https://github.com/hypertrial/househunter"],
[undefined, undefined, "https://github.com/hypertrial/rockyroad"],
["Archive", "https://stackingsats.org/", "https://github.com/hypertrial/stacksats"],
] as const;
const statuses = [undefined, undefined, "Beta", "Alpha", "Alpha", "Archived"] as const;
for (const [index, [action, destination, source]] of expected.entries()) {
const card = cards.nth(index);
const status = statuses[index];
if (status) await expect(card.getByText(status, { exact: true })).toBeVisible();
else await expect(card.getByText(/^(Alpha|Beta|Archived)$/)).toHaveCount(0);
const links = card.locator('a[target="_blank"]');
await expect(links).toHaveCount(action ? 2 : 1);
if (action) await expect(links.first()).toHaveAttribute("href", destination!);
await expect(links.last()).toHaveAttribute("href", source);
for (const link of await links.all()) {
await expect(link).toHaveAttribute("rel", /noopener.*noreferrer/);
await expect(link).toHaveAccessibleName(/opens in a new tab/);
}
}
await expect(page.getByText("Paid Target")).toHaveCount(0);
await cards.first().getByRole("link", { name: "TitanSkies", exact: true }).focus();
await page.keyboard.press("Tab");
await expect(cards.first().getByRole("link", { name: /Explore App/ })).toBeFocused();
await page.keyboard.press("Tab");
await expect(cards.first().getByRole("link", { name: /Live App/ })).toBeFocused();

await page.goto("/apps/stackingsats");
await expect(page.getByRole("link", { name: /Archive/ })).toHaveAttribute("href", "https://stackingsats.org/");
await expect(page.getByRole("link", { name: /Live App/ })).toHaveCount(0);
await expect(page.getByText(/historical|archiv/i).first()).toBeVisible();
});

test("source links distinguish matching guides from official sources", async ({ page }) => {
await page.goto("/apps/travelcanary");
const earthquake = page.getByRole("listitem").filter({ has: page.getByRole("heading", { name: "USGS earthquake feeds" }) });
await expect(earthquake.getByRole("link", { name: /Dataset Guide/ })).toHaveAttribute("href", "/datasets/usgs-earthquakes");
await expect(earthquake.getByRole("link", { name: /Official Source/ })).toHaveAttribute("href", "https://earthquake.usgs.gov/");
await expect(earthquake.getByRole("link", { name: /Official Source/ })).toHaveAttribute("target", "_blank");
await expect(earthquake.getByRole("link", { name: /Official Source/ })).toHaveAccessibleName(/opens in a new tab/);
await expect(page.getByText(/reviewed source inventory, not a live feed-health report/i)).toBeVisible();

await page.goto("/apps/househunter");
const nri = page.getByRole("listitem").filter({ has: page.getByRole("heading", { name: "FEMA National Risk Index" }) });
await expect(nri.getByRole("link", { name: /Dataset Guide/ })).toHaveCount(0);
await expect(nri).toContainText("FEMA National Flood Hazard Layer is a different dataset");
});
20 changes: 19 additions & 1 deletion e2e/design-experience.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';

const families = ['/', '/collections', '/collections/first-builds', '/themes/environment-hazards', '/datasets/usgs-earthquakes', '/datasets/nasa-firms', '/contribute', '/404'];
const families = ['/', '/apps', '/collections', '/collections/first-builds', '/themes/environment-hazards', '/datasets/usgs-earthquakes', '/datasets/nasa-firms', '/contribute', '/404'];
for (const width of [320, 390, 768, 1023, 1024, 1280, 1536]) {
test(`shared surfaces reflow at ${width}px`, async ({ page, browserName }, info) => {
test.skip(browserName !== 'chromium' && ![390, 1280].includes(width));
Expand All @@ -25,6 +25,24 @@ test('200% text and reduced motion retain search and reading context', async ({
}
});

test('TravelCanary source list remains readable at narrow and desktop widths and 200% text', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium');
for (const width of [320, 390, 1280]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/apps/travelcanary');
const sources = page.locator('main section[aria-labelledby="app-sources-title"] li');
expect(await sources.count()).toBeGreaterThan(20);
await sources.last().scrollIntoViewIfNeeded();
await expect(sources.last()).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
if (width === 390) {
await page.addStyleTag({ content: 'html { font-size: 200% !important; }' });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await expect(page.getByRole('heading', { level: 1, name: 'TravelCanary' })).toBeVisible();
}
}
});

test('guide anchors use the shared shell offset without an extra fixed-header gap', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
for (const viewport of [{ width: 390, height: 844 }, { width: 844, height: 390 }, { width: 1280, height: 800 }]) {
Expand Down
26 changes: 25 additions & 1 deletion e2e/metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { getActiveDatasets } from "../src/lib/datasets";
import { getAllCollections } from "../src/lib/collections";
import { DATASET_THEMES } from "../src/lib/schema";

const appNames = [
["titanskies", "TitanSkies"],
["hyperoptions", "HyperOptions"],
["travelcanary", "TravelCanary"],
["househunter", "HouseHunter"],
["rockyroad", "RockyRoad"],
["stackingsats", "StackingSats"],
] as const;

test("public routes expose canonical and social metadata", async ({ page }) => {
await page.goto("/");
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
Expand Down Expand Up @@ -81,6 +90,17 @@ test("dataset guides expose canonical metadata and valid JSON-LD", async ({
);
});

test("Apps listing and each project have their own canonical and social metadata", async ({ page }) => {
for (const [path, title] of [["/apps", "Apps"], ...appNames.map(([slug, name]) => [`/apps/${slug}`, name])] as const) {
await page.goto(path);
const canonical = `https://data.trilemma.foundation${path}`;
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute("href", canonical);
await expect(page.locator('meta[property="og:url"]')).toHaveAttribute("content", canonical);
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title);
await expect(page.locator('meta[name="description"]')).toHaveAttribute("content", /\S/);
}
});

test("robots and sitemap enumerate the public static application", async ({
request,
}) => {
Expand All @@ -96,6 +116,10 @@ test("robots and sitemap enumerate the public static application", async ({
expect(body).toContain("<loc>https://data.trilemma.foundation</loc>");
expect(body).toContain("<loc>https://data.trilemma.foundation/collections</loc>");
expect(body).toContain("<loc>https://data.trilemma.foundation/contribute</loc>");
expect(body).toContain("<loc>https://data.trilemma.foundation/apps</loc>");
for (const [slug] of appNames) {
expect(body).toContain(`<loc>https://data.trilemma.foundation/apps/${slug}</loc>`);
}
expect(body).not.toContain("<loc>https://data.trilemma.foundation/compare</loc>");
const datasets = getActiveDatasets();
for (const dataset of datasets) {
Expand All @@ -105,5 +129,5 @@ test("robots and sitemap enumerate the public static application", async ({
}
const extra =
1 + 1 + 1 + DATASET_THEMES.length + getAllCollections().length;
expect(body.match(/<loc>/g)).toHaveLength(datasets.length + extra);
expect(body.match(/<loc>/g)).toHaveLength(datasets.length + extra + 7);
});
94 changes: 94 additions & 0 deletions src/app/apps/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { AppActions } from "@/components/AppActions";
import { Badge } from "@/components/ui/badge";
import { apps, getAppBySlug, type AppSource } from "@/content/apps";
import { appsCopy } from "@/content/site-copy";
import { appPath, APPS_PATH, datasetPath, pageSocialMetadata } from "@/lib/seo";

export function generateStaticParams() {
return apps.map((app) => ({ slug: app.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const app = getAppBySlug(slug);
if (!app) return { title: "Page Not Found" };
return pageSocialMetadata(appPath(app.slug), app.title, app.summary);
}

export default async function AppDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const app = getAppBySlug(slug);
if (!app) notFound();

const groups = new Map<string, AppSource[]>();
for (const source of app.sources) {
const group = source.group ?? appsCopy.sourcesTitle;
groups.set(group, [...(groups.get(group) ?? []), source]);
}

return (
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
<nav aria-label="Breadcrumb" className="mb-4 text-sm text-muted-foreground">
<Link href={APPS_PATH} className="rounded-sm hover:text-link">{appsCopy.title}</Link>
</nav>
{app.status && (
<div className="mb-2"><Badge variant="outline">{appsCopy.statusLabels[app.status]}</Badge></div>
)}
<h1 className="font-heading text-3xl font-bold text-foreground sm:text-4xl">{app.title}</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-muted-foreground">{app.summary}</p>
<div className="mt-5"><AppActions app={app} /></div>

<section aria-labelledby="app-sources-title" className="mt-10">
<h2 id="app-sources-title" className="text-2xl font-semibold">{appsCopy.sourcesTitle}</h2>
<p className="mt-2 max-w-3xl text-base leading-7 text-muted-foreground">{app.sourceIntro}</p>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{appsCopy.sourceDisclosure}</p>
<p className="mt-3 text-sm text-muted-foreground">
{appsCopy.reviewedLabel} <time dateTime={app.reviewedAt}>{app.reviewedAt}</time> ·{" "}
<a href={app.sourceEvidenceUrl} target="_blank" rel="noopener noreferrer" className="font-semibold text-link hover:underline">
{appsCopy.sourceEvidenceLabel}<span className="sr-only"> (opens in a new tab)</span>
</a>
</p>
{Array.from(groups, ([group, sources]) => (
<section key={group} aria-labelledby={`source-group-${group.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`} className="mt-8">
<h3 id={`source-group-${group.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`} className="text-xl font-semibold">{group}</h3>
<ul className="mt-4 grid gap-4 md:grid-cols-2">
{sources.map((source) => (
<li key={source.name} className="rounded-2xl border border-[var(--tf-soft-periwinkle)] bg-white p-5">
<h4 className="text-lg font-semibold text-foreground">{source.name}</h4>
<p className="mt-2 text-sm"><strong>{appsCopy.sourceRoleLabel}:</strong> {source.role}</p>
<p className="mt-1 text-sm"><strong>{appsCopy.sourceAvailabilityLabel}:</strong> {source.availability}</p>
{source.coverage && <p className="mt-1 text-sm"><strong>{appsCopy.sourceCoverageLabel}:</strong> {source.coverage}</p>}
{source.details && <p className="mt-2 text-sm leading-6 text-muted-foreground">{source.details}</p>}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm font-semibold">
<a href={source.officialUrl} target="_blank" rel="noopener noreferrer" className="text-link hover:underline">
{appsCopy.officialSourceLabel}<span className="sr-only"> for {source.name} (opens in a new tab)</span>
</a>
{source.additionalUrls?.map(({ label, href }) => (
<a key={href} href={href} target="_blank" rel="noopener noreferrer" className="text-link hover:underline">
{label}<span className="sr-only"> (opens in a new tab)</span>
</a>
))}
{source.evidenceUrl && (
<a href={source.evidenceUrl} target="_blank" rel="noopener noreferrer" className="text-link hover:underline">
{appsCopy.sourceEvidenceLabel}<span className="sr-only"> for {source.name} (opens in a new tab)</span>
</a>
)}
{source.guideId && (
<Link href={datasetPath(source.guideId)} className="text-link hover:underline">
{appsCopy.guideLabel}<span className="sr-only"> for {source.name}</span>
</Link>
)}
</div>
{source.noGuideReason && <p className="mt-3 text-sm text-muted-foreground">{source.noGuideReason}</p>}
</li>
))}
</ul>
</section>
))}
</section>
</div>
);
}
Loading
Loading