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
15 changes: 9 additions & 6 deletions app/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Link from "next/link";
import { repoUrl } from "@/lib/github";
import { casePath, headingText, useCases } from "@/lib/use-cases";
import { Logo } from "./logo";

const repo = "https://github.com/david-sling/wave";
import { StarButton } from "./star-button";

export function Footer() {
return (
Expand All @@ -24,6 +24,10 @@ export function Footer() {
</a>
.
</p>
{/* Closing the block that says the project is open source, rather
than sitting in the Project column, where it would have been a
list item repeating what that sentence already said. */}
<StarButton className="mt-2 self-start" />
</div>

<div className="flex flex-col gap-8 sm:flex-row sm:gap-16">
Expand Down Expand Up @@ -62,10 +66,9 @@ export function Footer() {
Project
</h2>
<ul className="m-0 grid list-none gap-y-2 p-0 text-[14px] text-ink-2">
<li><a className="no-underline hover:text-ink" href={repo}>GitHub</a></li>
<li><a className="no-underline hover:text-ink" href={`${repo}/blob/main/docs/PRODUCT.md`}>Product definition</a></li>
<li><a className="no-underline hover:text-ink" href={`${repo}/blob/main/docs/ARCHITECTURE.md`}>Architecture</a></li>
<li><a className="no-underline hover:text-ink" href={`${repo}/issues`}>Issues</a></li>
<li><a className="no-underline hover:text-ink" href={`${repoUrl}/blob/main/docs/PRODUCT.md`}>Product definition</a></li>
<li><a className="no-underline hover:text-ink" href={`${repoUrl}/blob/main/docs/ARCHITECTURE.md`}>Architecture</a></li>
<li><a className="no-underline hover:text-ink" href={`${repoUrl}/issues`}>Issues</a></li>
</ul>
</nav>
</div>
Expand Down
19 changes: 11 additions & 8 deletions app/components/nav.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { Logo } from "./logo";
import { NavVeil } from "./nav-veil";
import { StarButton } from "./star-button";

const links = [
{ hash: "#how", label: "How it works" },
{ hash: "#uses", label: "Use cases" },
{ hash: "#agents", label: "Agents" },
];

const selfHost = "https://github.com/david-sling/wave";

/**
* The primary nav.
*
Expand All @@ -17,6 +16,10 @@ const selfHost = "https://github.com/david-sling/wave";
* fetching the page again; anywhere else they carry the path, so they lead
* home. `Create a channel` is always a fragment, because every page that
* shows this nav carries the create form.
*
* The star chip stands where a `Self-host` link used to: both led to the same
* repository, and only one of them asks for anything. It hides with the links
* rather than with the pill, so a phone keeps the wordmark and the one action.
*/
export function Nav({ atHome = true }: { atHome?: boolean }) {
const home = atHome ? "" : "/";
Expand All @@ -31,13 +34,13 @@ export function Nav({ atHome = true }: { atHome?: boolean }) {
{link.label}
</a>
))}
<a href={selfHost} className="no-underline hover:text-ink">
Self-host
</a>
</nav>
<a href="#create" className="btn btn-primary btn-sm">
Create a channel
</a>
<div className="flex items-center gap-3">
<StarButton label="Star" className="hidden md:inline-flex" />
<a href="#create" className="btn btn-primary btn-sm">
Create a channel
</a>
</div>
</div>
</header>
);
Expand Down
70 changes: 70 additions & 0 deletions app/components/star-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { formatStars, repoUrl, starCount } from "@/lib/github";

/**
* GitHub's own mark.
*
* Referential use, under the same policy as `lib/client-marks.ts`: the glyph
* is the official one as published (Simple Icons, CC0 1.0,
* https://simpleicons.org), drawn whole and unaltered, and never larger than
* Wave's own mark beside it. It lives here rather than in `icons.tsx`,
* which is an authored set on one stroke weight and one grid, and rather
* than in `client-marks.ts`, which is keyed by the agent clients that can
* join a channel. GitHub is neither.
*/
function GitHubMark({ size = 16 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
focusable="false"
className="shrink-0"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}

/**
* The one place the page asks for something other than a channel.
*
* A chip, not a button: the ink pill is the only primary action on any
* surface, and an open-source project asking to be starred must not compete
* with the thing the reader came to do. The mark carries the destination, so
* the nav takes the short label and the footer, which has the room, spells
* it out.
*
* The count is shown whatever it is, nought included. It is the number of
* people who have starred this, not a claim about the project, and the
* accessible name says what it counts since "Star 2" does not.
*/
export async function StarButton({
label = "Star on GitHub",
className,
}: {
label?: string;
className?: string;
}) {
const stars = await starCount();
return (
<a
href={repoUrl}
aria-label={
stars === null
? "Star Wave on GitHub"
: `Star Wave on GitHub, ${stars} ${stars === 1 ? "star" : "stars"}`
}
className={`chip no-underline transition-colors hover:border-line-strong ${className ?? ""}`}
>
<GitHubMark />
<span aria-hidden>{label}</span>
{stars !== null && (
<span aria-hidden className="tabular-nums text-ink-3">
{formatStars(stars)}
</span>
)}
</a>
);
}
72 changes: 72 additions & 0 deletions lib/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { formatStars, starCount } from './github'

/**
* The count is decoration, so what matters is that nothing it touches can
* take a page down with it, and that an unknown count stays distinguishable
* from a real count of nought.
*/
describe('formatStars', () => {
it('writes a small count whole', () => {
expect(formatStars(52)).toBe('52')
expect(formatStars(999)).toBe('999')
})

it('writes a thousand as a rounded k, without a trailing .0', () => {
expect(formatStars(1000)).toBe('1k')
expect(formatStars(1249)).toBe('1.2k')
expect(formatStars(9950)).toBe('10k')
expect(formatStars(12400)).toBe('12k')
})
})

describe('starCount', () => {
const answer = (body: unknown, ok = true) =>
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok, json: async () => body }) as unknown as Response),
)

afterEach(() => {
vi.unstubAllGlobals()
// `starCount` is memoised per render pass; tests share one.
;(starCount as unknown as { clear?: () => void }).clear?.()
})

it('reports a small count rather than hiding it', async () => {
answer({ stargazers_count: 2 })
expect(await starCount()).toBe(2)
})

it('tells nought apart from not knowing', async () => {
answer({ stargazers_count: 0 })
expect(await starCount()).toBe(0)
})

it('returns a count worth reading', async () => {
answer({ stargazers_count: 412 })
expect(await starCount()).toBe(412)
})

it('returns nothing when GitHub refuses, rather than throwing', async () => {
answer({ message: 'API rate limit exceeded' }, false)
expect(await starCount()).toBeNull()
})

it('returns nothing when the request fails outright', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('timed out')
}),
)
expect(await starCount()).toBeNull()
})

it('returns nothing when the field is missing or not a number', async () => {
answer({})
expect(await starCount()).toBeNull()
answer({ stargazers_count: '412' })
expect(await starCount()).toBeNull()
})
})
60 changes: 60 additions & 0 deletions lib/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* The project's own repository, and how many people have starred it.
*
* The URL lives here because the star control, the nav and the footer all
* point at it. The count is a marketing number and nothing depends on it, so
* every failure path returns `null` and the control renders as a plain ask:
* a landing page must not fail to build because GitHub was slow or had run
* out of anonymous requests for this IP.
*/

import { cache } from "react";

export const repoUrl = "https://github.com/david-sling/wave";

const api = "https://api.github.com/repos/david-sling/wave";

/** How long a count is served before it is fetched again. */
const REVALIDATE_SECONDS = 3600;

/** Past this the request is abandoned; a stale count beats a slow page. */
const TIMEOUT_MS = 2500;

/**
* The star count, or `null` when GitHub did not answer.
*
* `null` means unknown, not zero: a count of nought is a real answer and is
* shown as one.
*
* Wrapped in `cache` rather than left to fetch's own memoisation, because the
* abort signal opts this request out of that (it matches on the signal), and
* the nav and the footer both ask within one render.
*/
export const starCount = cache(async (): Promise<number | null> => {
try {
const response = await fetch(api, {
headers: { accept: "application/vnd.github+json" },
signal: AbortSignal.timeout(TIMEOUT_MS),
next: { revalidate: REVALIDATE_SECONDS },
});
if (!response.ok) return null;
const body: unknown = await response.json();
const stars = (body as { stargazers_count?: unknown }).stargazers_count;
if (typeof stars !== "number" || !Number.isFinite(stars)) return null;
return Math.max(0, Math.floor(stars));
} catch {
return null;
}
});

/**
* A count at the width a nav chip can hold: 128, 1.2k, 12k.
*
* Rounded to tenths before the branch, not after: `toFixed` on the raw
* quotient reads 9950 as 9.9k, because 9.95 is not 9.95 in binary.
*/
export function formatStars(count: number): string {
if (count < 1000) return String(count);
const tenths = Math.round(count / 100) / 10;
return `${tenths < 10 ? tenths : Math.round(tenths)}k`;
}
Loading