From a0d7f53b2c4a85d363260b80fbd1aadd91362cfb Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 16:05:12 +1000
Subject: [PATCH 1/9] Redraw the directory as a roster against the credential
model
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The directory card was written before `docs/spec/profile-and-credentials.md` existed and rendered a model that no longer does: a profile-level `verified`, a `certified` flag that was declared and never read, and a `role` field the schema calls `headline`. Same failure `docs/scope.md` had before #54 — written against a superseded vocabulary, and reading as current because it once was.
Three variants were prototyped on a throwaway route and the roster won, so this commits the roster and deletes the rest.
Rows rather than a grid of cards, because the job here is comparing practitioners and a grid is a poor shape for it: each card is read on its own and nothing lines up between them. In rows the credentials sit in a column, so "who has actually been checked" is a vertical scan rather than eight separate readings.
Two changes are load-bearing rather than stylistic. The badge now sits on the credential column instead of beside the person's name — the spec calls that a UI constraint the directory work inherits, because a narrow badge placed where it reads as a whole-profile endorsement is misleading no matter what the clearing rule does, and no schema rule can fix that reading. And the badge is derived rather than read: there is no profile-level `verified` column, so `hasVerifiedBadge` computes it from the credential rows, as does the "Verified only" filter.
The types now mirror the columns `anon` is granted and nothing else, so a component cannot reach for a field the directory will never be able to select. That adds `countryCode` and per-credential `verified`, `earnedAt` and `evidenceUrl`, and removes `certified` — which the spec derives rather than stores.
The location filter groups on `countryCode`, not on `location`. `location` is deliberately free text at whatever granularity the practitioner chose, so "Sydney" beside "Bengaluru, Karnataka, India (remote)" will never collapse into a usable set of chips; `country_code` is the column that exists for this. Filters are now grouped and labelled, because a country, a focus area and "Verified only" are three different kinds of claim and one flat row of chips read as an undifferentiated pile once countries were added.
Two states the old card could not draw now render: a profile with no credentials at all, which nothing in the DDL forbids, and one that is entirely working-towards, which must not read as a failed check because that group is one the directory exists to include.
The Enquire button carries `?about=` to `/contact`, which names the practitioner in the page, the mail subject and the body. Enquiries still route through Bluehex — no address is published on a profile. This makes `/contact` server-rendered per request rather than prerendered; the alternative, reading the param client-side under Suspense, keeps the static shell but flashes a fallback over the page's main content.
Verified by temporarily populating the directory with four profiles covering the awkward cases and re-running the suite, since an empty directory exercises no rows at all: axe clean on the row markup in both viewports, the badge on exactly the profile whose earned credentials were all verified and absent on the mixed one.
No flag is drawn yet — `countryCode` powers the filter, and the SVG assets are their own item in scope.md. `Practitioner` keeps its name despite CONTEXT.md noting it conflates the human with the record; that rename reaches the component, its file and its props, and belongs with #53.
---
src/app/contact/contact-form.tsx | 19 +-
src/app/contact/page.tsx | 12 +-
src/components/practitioner-directory.tsx | 362 +++++++++++++++++-----
src/lib/practitioners.ts | 94 +++++-
4 files changed, 391 insertions(+), 96 deletions(-)
diff --git a/src/app/contact/contact-form.tsx b/src/app/contact/contact-form.tsx
index 3abfea8..aaf4b58 100644
--- a/src/app/contact/contact-form.tsx
+++ b/src/app/contact/contact-form.tsx
@@ -15,8 +15,13 @@ const fieldClasses =
* looks like it worked and silently loses the enquiry. Replacing this with a
* route handler or a form service is tracked in issue #2 — when that lands,
* the mailto fallback should stay for anyone with JavaScript disabled.
+ *
+ * `about` is the practitioner a directory enquiry concerns. Enquiries route
+ * through Bluehex rather than to the practitioner directly — no address is
+ * ever published on a profile — so this only has to say who was meant, and the
+ * mail still comes here.
*/
-export function ContactForm({ email }: { email: string }) {
+export function ContactForm({ email, about }: { email: string; about?: string }) {
const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
@@ -27,12 +32,15 @@ export function ContactForm({ email }: { email: string }) {
`Name: ${value("name")}`,
`Email: ${value("email")}`,
`Phone: ${value("phone")}`,
+ ...(about ? [`About: ${about}`] : []),
"",
value("message"),
].join("\n");
const query = new URLSearchParams({
- subject: `Enquiry from ${value("name") || "the website"}`,
+ subject: about
+ ? `Enquiry about ${about}, from ${value("name") || "the website"}`
+ : `Enquiry from ${value("name") || "the website"}`,
body,
});
@@ -41,6 +49,13 @@ export function ContactForm({ email }: { email: string }) {
return (
-
+
diff --git a/src/components/practitioner-directory.tsx b/src/components/practitioner-directory.tsx
index adcc39c..023409b 100644
--- a/src/components/practitioner-directory.tsx
+++ b/src/components/practitioner-directory.tsx
@@ -1,27 +1,50 @@
"use client";
-import { useMemo, useRef, useState } from "react";
+import { useId, useMemo, useRef, useState } from "react";
import { Close, Search, Sparkle } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
-import type { Practitioner } from "@/lib/practitioners";
+import {
+ countryName,
+ hasVerifiedBadge,
+ type Credential,
+ type Practitioner,
+} from "@/lib/practitioners";
/**
- * The practitioner directory: a search box, a row of filters and the grid of
- * profiles underneath.
+ * The practitioner directory: a search box, filters, and a roster of profiles
+ * underneath.
+ *
+ * A roster of rows rather than a grid of cards, because the job someone does
+ * here is *comparing* practitioners, and a grid is a poor shape for that — each
+ * card is read on its own and nothing lines up between them. In rows the
+ * credentials sit in a column, so "who has actually been checked" is a
+ * vertical scan rather than eight separate readings.
*
* Everything matches client-side against the practitioner list, which ships
* with the page — the directory is small enough that a round trip per keystroke
* would only add latency. If the list outgrows that, this is the seam to move
* behind a route handler; the props stay the same.
+ *
+ * Two things here are load-bearing rather than stylistic:
+ *
+ * - **The badge sits with the credentials, never beside the name.** It
+ * attests to evidence a human read — the credentials, and the name they
+ * are attached to — and never to `bio`, `headline`, `focus` or `location`.
+ * Placed beside the name it reads as a whole-profile endorsement, which is
+ * a claim Bluehex has no method for. No schema rule can fix that reading,
+ * so the placement is the mitigation. See the spec.
+ * - **The badge is derived, not read.** There is no profile-level `verified`
+ * column. `hasVerifiedBadge` computes it from the credential rows, and the
+ * "Verified only" filter computes it too.
*/
/** Every field a query is matched against, flattened once per practitioner. */
function searchIndex(person: Practitioner) {
return [
person.name,
- person.role,
- person.location,
- person.bio,
+ person.headline ?? "",
+ person.location ?? "",
+ person.bio ?? "",
...person.focus,
...person.credentials.flatMap((credential) => [credential.label, credential.source]),
]
@@ -43,6 +66,7 @@ function matchesQuery(person: Practitioner, query: string) {
export function PractitionerDirectory({ practitioners }: { practitioners: Practitioner[] }) {
const [query, setQuery] = useState("");
const [verifiedOnly, setVerifiedOnly] = useState(false);
+ const [countryFilters, setCountryFilters] = useState([]);
const [focusFilters, setFocusFilters] = useState([]);
const searchBox = useRef(null);
@@ -53,28 +77,46 @@ export function PractitionerDirectory({ practitioners }: { practitioners: Practi
[practitioners],
);
+ /* Location filtering groups on `countryCode`, not on `location`. `location`
+ is free text at whatever granularity the practitioner chose — "Sydney"
+ next to "Bengaluru, Karnataka, India (remote)" — so it will never collapse
+ into a usable set of chips. The country code exists for exactly this. */
+ const countries = useMemo(
+ () =>
+ /* flatMap over `?? []` drops the nulls and narrows the type in one step. */
+ [...new Set(practitioners.flatMap((person) => person.countryCode ?? []))]
+ .map((code) => ({ code, name: countryName(code) }))
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ [practitioners],
+ );
+
const results = useMemo(
() =>
practitioners.filter((person) => {
- if (verifiedOnly && !person.verified) return false;
+ if (verifiedOnly && !hasVerifiedBadge(person.credentials)) return false;
+ if (countryFilters.length) {
+ if (!person.countryCode || !countryFilters.includes(person.countryCode)) return false;
+ }
if (focusFilters.length && !focusFilters.some((item) => person.focus.includes(item))) {
return false;
}
return matchesQuery(person, query);
}),
- [practitioners, query, verifiedOnly, focusFilters],
+ [practitioners, query, verifiedOnly, countryFilters, focusFilters],
);
- const filtering = query.trim() !== "" || verifiedOnly || focusFilters.length > 0;
+ const filtering =
+ query.trim() !== "" || verifiedOnly || countryFilters.length > 0 || focusFilters.length > 0;
- const toggleFocus = (area: string) =>
- setFocusFilters((current) =>
- current.includes(area) ? current.filter((item) => item !== area) : [...current, area],
+ const toggle = (setter: typeof setFocusFilters) => (value: string) =>
+ setter((current) =>
+ current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
);
const clearAll = () => {
setQuery("");
setVerifiedOnly(false);
+ setCountryFilters([]);
setFocusFilters([]);
};
@@ -83,8 +125,9 @@ export function PractitionerDirectory({ practitioners }: { practitioners: Practi
Find a Claude practitioner.
- Anyone in the community can publish a profile. Verified{" "}
- means Bluehex has checked the credentials on it against the certificates that issued them.
+ Anyone in the community can publish a profile.{" "}
+ Verified means Bluehex has
+ checked that credential against the certificate that issued it.
- setVerifiedOnly(!verifiedOnly)}>
- Verified only
-
-
- {focusAreas.map((area) => (
- toggleFocus(area)}
- >
- {area}
+ {/* Grouped rather than one flat row of chips: "Verified", a country and a
+ focus area are three different kinds of claim, and mixing them makes
+ the row read as one undifferentiated pile. Groups whose source data is
+ empty render nothing at all. */}
+
+ );
+}
+
function FilterChip({
pressed,
onClick,
@@ -219,46 +323,148 @@ function FilterChip({
);
}
-function PractitionerCard({ person }: { person: Practitioner }) {
+function PractitionerRow({ person }: { person: Practitioner }) {
return (
-
- {/* min-w-0 so a long name or role wraps instead of shoving the badge off
- the card — profile text is user-supplied and unbounded. */}
-
-
-
{person.name}
-
{person.role}
-
{person.location}
-
- {/* The badge is only ever about the Bluehex check. Where someone is up
- to with certification shows per credential in the list below. */}
-
- {person.verified ? "Verified" : "Self-listed"}
-
+ <>
+ {/* Practitioner. No badge in this column, deliberately — see the note at
+ the top of the file. `countryCode` drives the location filter and is
+ what a flag would be drawn from; the flag asset itself is its own
+ ticket, so nothing renders it here yet. */}
+
+ {/* Focus. Self-described and never vetted — which is how every directory
+ works, and the reason the badge's placement above is load-bearing. */}
+
{person.focus.map((item) => (
{item}
))}
-
+
+ {/* Enquiries go through Bluehex: no address on the row, and the link
+ carries who the enquiry is about so the form can say so. */}
+
+ Enquire
+ about {person.name}
+
+ >
+ );
+}
+
+/** `earnedAt` is a date, not a timestamp — read and formatted as one. */
+function earnedLabel(credential: Credential) {
+ if (!credential.earnedAt) return "Working towards";
+ const date = new Date(`${credential.earnedAt}T00:00:00Z`);
+ return `Earned ${date.toLocaleDateString("en-AU", {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ })}`;
+}
+
+/**
+ * The per-credential state, which is where all the nuance lives — the profile
+ * badge stays binary and everything else is said here. Three states, and they
+ * have to be distinguishable at a glance down a column:
+ *
+ * verified — a human at Bluehex read the evidence
+ * earned — claimed, not yet checked
+ * towards — no `earnedAt`; unverifiable, and outside the badge rollup
+ */
+function CredentialMark({ credential }: { credential: Credential }) {
+ if (!credential.earnedAt) {
+ return (
+
+ Working towards.
+
+ );
+ }
+
+ if (!credential.verified) {
+ return (
+
+
+ Earned, not yet checked by Bluehex.
+
+ );
+ }
+
+ return (
+
+
+ Verified by Bluehex.
+
+ );
+}
+
+function Tick({ className = "" }: { className?: string }) {
+ return (
+
);
}
diff --git a/src/lib/practitioners.ts b/src/lib/practitioners.ts
index 9371e06..6c48854 100644
--- a/src/lib/practitioners.ts
+++ b/src/lib/practitioners.ts
@@ -9,7 +9,22 @@
*
* REAL PEOPLE ONLY. Nothing here is placeholder copy — a profile goes in when
* the person has agreed to be published and the credentials have been checked.
- * The home page renders open slots for the rest rather than inventing entries.
+ * The home page renders an invitation for the empty directory rather than
+ * inventing entries.
+ *
+ * These types are the *public* view of a profile, and they deliberately mirror
+ * the columns `anon` is granted in `docs/spec/profile-and-credentials.md` —
+ * nothing more. The directory is an anonymous read, so a field absent from that
+ * grant list is one the page can never render, and putting it here would only
+ * invite a component to reach for it. Notably absent: `status`, `user_id`, and
+ * the raw `evidence_url` (only the practitioner's published one survives).
+ *
+ * Fields are camelCase here and snake_case in Postgres; the mapping happens
+ * where the query does, which is #53.
+ *
+ * CONTEXT.md notes that naming this type `Practitioner` conflates the human
+ * with the record — strictly it is a Profile. Left alone for now because the
+ * rename reaches the component, its file and its props; it belongs with #53.
*/
export type CredentialSource = "Claude Certification" | "Anthropic Academy";
@@ -17,26 +32,77 @@ export type CredentialSource = "Claude Certification" | "Anthropic Academy";
export type Credential = {
source: CredentialSource;
label: string;
- /** Left off until the credential is earned — drives the "in progress" state. */
- earned?: string;
+ /**
+ * The day it was earned, `YYYY-MM-DD`. Null means working towards — and a
+ * working-towards credential is inherently unverifiable, so it sits outside
+ * the badge rollup entirely rather than counting against it.
+ */
+ earnedAt: string | null;
+ /**
+ * Bluehex checked the evidence behind *this credential*. Verification is per
+ * credential and never per profile: Bluehex checks certificates one at a
+ * time, and "three credentials, two checked" is a real state that a
+ * profile-level boolean cannot hold. Only Bluehex sets this.
+ */
+ verified: boolean;
+ /**
+ * The certificate URL, but only when the practitioner chose to publish it —
+ * this is the generated `evidence_url_public` column, which is null unless
+ * `evidence_public` is true. A verified credential with no link here is
+ * normal, not missing data: publishing a Skilljar page exposes the holder's
+ * full legal name, so it is their call.
+ */
+ evidenceUrl: string | null;
};
export type Practitioner = {
+ id: string;
name: string;
- role: string;
- location: string;
- /** Holds a Claude Certification. Comes from Anthropic, claimed by the person. */
- certified: boolean;
+ /** What they do, in a line. Called `headline` in the schema, not `role`. */
+ headline: string | null;
+ /** Free text, at whatever granularity the practitioner chose. Not filterable. */
+ location: string | null;
/**
- * Bluehex has checked the credentials on this profile. Independent of
- * `certified`: anyone can publish a profile before it has been checked, and a
- * practitioner still working towards certification can have the Academy
- * certificates they do hold verified. Only Bluehex sets this.
+ * ISO 3166-1 alpha-2. Separate from `location` because a machine cannot
+ * reliably derive one from the other — this is what the location filter
+ * groups on, and what a flag would be drawn from.
*/
- verified: boolean;
- bio: string;
- credentials: Credential[];
+ countryCode: string | null;
+ bio: string | null;
focus: string[];
+ credentials: Credential[];
};
+/**
+ * Whether the profile shows the Verified badge.
+ *
+ * Derived, never stored — consistent with `certified`, which is not stored
+ * either and is simply "has an earned Claude Certification credential".
+ *
+ * The rule: at least one earned credential, and every earned credential
+ * verified. In-progress credentials are excluded rather than counted as
+ * unverified, because counting them would permanently deny the badge to anyone
+ * working towards a certification — a group this directory exists to include.
+ *
+ * Cheap because the directory fetches every profile and filters in the browser.
+ * Materialise it into a column when that stops being true.
+ */
+export function hasVerifiedBadge(credentials: Credential[]) {
+ const earned = credentials.filter((credential) => credential.earnedAt);
+ return earned.length > 0 && earned.every((credential) => credential.verified);
+}
+
+/* `Intl` already ships every country name, so a lookup table here would be a
+ few kilobytes of data to maintain for no gain. Built once, not per render. */
+const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
+
+/** "AU" → "Australia". Falls back to the code if it is not a known region. */
+export function countryName(code: string) {
+ try {
+ return regionNames.of(code) ?? code;
+ } catch {
+ return code;
+ }
+}
+
export const practitioners: Practitioner[] = [];
From 26cf37abde2c7b3b420062f98acf3fef05903bf3 Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:03:59 +1000
Subject: [PATCH 2/9] Resolve the contact page's ?about= against the roster
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The parameter was echoed straight into the page, the mailto subject and the mail body, so `/contact?about=` rendered attacker-supplied text as Bluehex's own copy. It now carries a profile id and is resolved to a name against `practitioners`; anything that does not match renders no banner at all.
Carrying the id rather than the display name is right for a second reason that outlives this fix. Names are not identifiers — two practitioners can share one and the enquiry would not say which — which is the same argument that keeps the display name out of a profile URL.
Nothing legitimately linked here, because the directory ships empty, so there is no compatibility concern.
---
src/app/contact/page.tsx | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx
index 3dcc855..78cbfee 100644
--- a/src/app/contact/page.tsx
+++ b/src/app/contact/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { ContactForm } from "./contact-form";
import { ArrowRight, ArrowUpRight } from "@/components/icons";
import { Button, SectionLabel } from "@/components/ui";
+import { practitioners } from "@/lib/practitioners";
import { site } from "@/lib/site";
export const metadata: Metadata = {
@@ -15,8 +16,24 @@ const BOOKING_URL =
export default async function ContactPage({ searchParams }: PageProps<"/contact">) {
/* The directory's Enquire button carries who the enquiry is about. Read here
rather than with `useSearchParams` in the form, which would need a Suspense
- boundary and push the whole page to client rendering for one string. */
- const about = (await searchParams).about;
+ boundary and push the whole page to client rendering for one string.
+
+ It carries the profile **id**, not the display name, and the name shown is
+ looked up from it. Two reasons, and the second is the serious one:
+
+ 1. Names are not identifiers. Two practitioners can share one, and the
+ enquiry would not say which — the same argument that rules the display
+ name out of a profile URL.
+ 2. The previous version echoed the query string straight into the page, the
+ mailto subject and the mail body. That is unvalidated, attacker-supplied
+ text rendered as if Bluehex wrote it, so `?about=` produced a
+ page that appeared to endorse it. Resolving against the known set means
+ anything that does not match simply shows no banner. */
+ const requested = (await searchParams).about;
+ const about =
+ typeof requested === "string"
+ ? practitioners.find((person) => person.id === requested)?.name
+ : undefined;
return (
<>
@@ -40,10 +57,7 @@ export default async function ContactPage({ searchParams }: PageProps<"/contact"
anytime.
-
+
From 54656cf90c57556d172b1ab836cf7131aa423144 Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:04:10 +1000
Subject: [PATCH 3/9] Give every profile a URL and point the roster at it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`profilePath` generates `/p/mara-ellison-9f3c1a`, where the trailing short id is what resolves and the slug is decoration — a rename changes the URL without breaking the old one, and the route serves a canonical redirect when the slug no longer matches. The id is the first six characters of the row's uuid and must never be derived from the name, since hashing the name would move the id whenever the name changed, which is the exact failure the scheme exists to prevent.
What carried the decision was analytics rather than depth. A full page adds only three fields over the roster row, so on substance alone it does not earn a route; but a path segment gives referrer data, which is how you learn an employer arrived from a candidate's job application, and indexability, which is how somebody searching for a Claude consultant lands on a profile at all. Expand-in-place can never answer either.
The row's control changes from Enquire to View profile. One call to action per surface: the directory gets you to a profile, the profile gets you to enquire.
`/p/[handle]` here is prod-guarded and fixture-backed — a drawing for the real implementation to replace, not the implementation. This leaves a deliberate dangling link, and it is the thing to watch: nothing breaks today because the directory ships empty, so no row and therefore no link renders, but the profile route has to land before the first practitioner is added or the only control on a row 404s and enquiries have no path at all.
---
src/app/p/[handle]/page.tsx | 77 ++++++++++++
src/app/p/_lib/credential-mark.tsx | 57 +++++++++
src/app/p/_lib/handles.ts | 27 +++++
src/app/p/_lib/profile-detail.tsx | 138 ++++++++++++++++++++++
src/components/practitioner-directory.tsx | 30 +++--
src/lib/practitioners.ts | 24 ++++
6 files changed, 346 insertions(+), 7 deletions(-)
create mode 100644 src/app/p/[handle]/page.tsx
create mode 100644 src/app/p/_lib/credential-mark.tsx
create mode 100644 src/app/p/_lib/handles.ts
create mode 100644 src/app/p/_lib/profile-detail.tsx
diff --git a/src/app/p/[handle]/page.tsx b/src/app/p/[handle]/page.tsx
new file mode 100644
index 0000000..8f1c115
--- /dev/null
+++ b/src/app/p/[handle]/page.tsx
@@ -0,0 +1,77 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { profilePath } from "@/lib/practitioners";
+import { findByHandle } from "../_lib/handles";
+import { ProfileDetail } from "../_lib/profile-detail";
+
+/**
+ * A profile at its real URL. **Never production** — the guard below 404s it, so
+ * production behaviour is unchanged from having no route here at all.
+ *
+ * It sits at `/p/` rather than under `/prototype/` on purpose. The directory
+ * links here from production code (`profilePath` in `@/lib/practitioners`), so
+ * a prototype at a different path would need the link faked, and faking the one
+ * thing the design is built around — that the URL is real and shareable — would
+ * make the prototype prove nothing. This is also the file the real page
+ * replaces, which is the right shape for a drawing that is meant to be thrown
+ * away from underneath.
+ *
+ * What makes it a drawing is the data: it reads the throwaway fixture in
+ * `@/app/prototype/directory/fixtures`, which is a deliberately awkward import.
+ * A page under `/p/` reaching into `/prototype/` should look wrong, because it
+ * is the part that must not survive.
+ *
+ * Every arrival renders this — clicked from the directory, pasted from a CV, or
+ * found in search. An earlier version intercepted the click into a drawer over
+ * the directory so the visitor kept their search context; that was cut. See
+ * NOTES.md.
+ */
+
+export async function generateMetadata({
+ params,
+}: PageProps<"/p/[handle]">): Promise {
+ const person = findByHandle((await params).handle);
+
+ return {
+ title: person ? `${person.name} — Bluehex` : "Profile",
+ /* Noindex is a property of the prototype, not of the design. The real page
+ is indexable on purpose — organic search is a third of why it has a URL. */
+ robots: { index: false, follow: false },
+ };
+}
+
+export default async function ProfilePage({ params }: PageProps<"/p/[handle]">) {
+ if (process.env.NODE_ENV === "production") notFound();
+
+ const handle = (await params).handle;
+ const person = findByHandle(handle);
+ if (!person) notFound();
+
+ const canonical = profilePath(person).replace("/p/", "");
+
+ return (
+
+
+
+ Directory
+ {" "}
+ / {person.name}
+
+
+
+
+ {handle !== canonical ? (
+
+
This is not the canonical URL
+
+ You asked for {handle} and the canonical one is{" "}
+ {canonical}. It resolved anyway, because only the trailing short
+ id is read — which is how a rename keeps old links alive. A real build would
+ redirect here rather than serve both.
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/p/_lib/credential-mark.tsx b/src/app/p/_lib/credential-mark.tsx
new file mode 100644
index 0000000..845b084
--- /dev/null
+++ b/src/app/p/_lib/credential-mark.tsx
@@ -0,0 +1,57 @@
+/**
+ * The credential furniture the drawer and the page both use.
+ *
+ * Trimmed when this surface settled: the variant fixtures and the identity
+ * panel are gone, because the identity question they existed to force has an
+ * answer and it now lives in `handles.ts` where the code that implements it is.
+ */
+
+import type { Credential } from "@/lib/practitioners";
+
+export function earnedLabel(credential: Credential) {
+ if (!credential.earnedAt) return "Working towards";
+ const date = new Date(`${credential.earnedAt}T00:00:00Z`);
+ return `Earned ${date.toLocaleDateString("en-AU", {
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ })}`;
+}
+
+/**
+ * The three states a credential can be in, as one mark: working towards, earned
+ * but not yet checked, and verified. Distinguished by shape rather than by
+ * colour alone, and each carries its own screen-reader text — the difference
+ * between the second and third is the entire product.
+ */
+export function CredentialMark({ credential }: { credential: Credential }) {
+ if (!credential.earnedAt) {
+ return (
+
+ Working towards.
+
+ );
+ }
+ if (!credential.verified) {
+ return (
+
+
+ Earned, not yet checked by Bluehex.
+
+ );
+ }
+ return (
+
+
+ Verified by Bluehex.
+
+ );
+}
diff --git a/src/app/p/_lib/handles.ts b/src/app/p/_lib/handles.ts
new file mode 100644
index 0000000..ab7a527
--- /dev/null
+++ b/src/app/p/_lib/handles.ts
@@ -0,0 +1,27 @@
+/**
+ * Resolving a profile handle back to a profile.
+ *
+ * The *generating* half of this lives in `@/lib/practitioners` as
+ * `profilePath`, because the directory needs it to render links and that is
+ * production code. Only the lookup is here, and only because what it looks up
+ * is a throwaway fixture — a real build resolves against the database.
+ *
+ * There must be exactly one scheme. An earlier version of this file had its own
+ * `profileHandle` that hashed the *name* into a short id, which disagreed with
+ * production's "first six characters of the uuid" the moment both existed: the
+ * directory linked one way and the page resolved another. It is deleted rather
+ * than reconciled.
+ *
+ * The lookup reads only the trailing short id and ignores the slug, which is
+ * what makes a URL survive a rename — `/p/mara-ellison-9f3c1a` and
+ * `/p/her-new-name-9f3c1a` are the same profile, and a real build would notice
+ * the mismatch and serve a canonical redirect.
+ */
+
+import { launchPopulation } from "@/app/prototype/directory/fixtures";
+
+export function findByHandle(handle: string) {
+ const id = handle.split("-").at(-1);
+ if (!id) return null;
+ return launchPopulation.find((person) => person.id.slice(0, 6) === id) ?? null;
+}
diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx
new file mode 100644
index 0000000..a528983
--- /dev/null
+++ b/src/app/p/_lib/profile-detail.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+/**
+ * One profile.
+ *
+ * It rendered in two containers for a while — a drawer over the directory on a
+ * click, a page on a cold arrival, one component behind both. The drawer was cut
+ * along with the route interception that produced it; see NOTES.md for why. What
+ * is left is the page, which was always the half that had to work.
+ *
+ * What is here and not on the roster row: the bio, the earned dates, and the
+ * credential sources. Three fields. The page is not justified by that depth —
+ * it is justified by having a URL, which is a different argument and the one
+ * that actually held.
+ */
+
+import { useState } from "react";
+import { Badge } from "@/components/ui";
+import { hasVerifiedBadge, profilePath, type Practitioner } from "@/lib/practitioners";
+import { CredentialMark, earnedLabel } from "./credential-mark";
+
+export function ProfileDetail({ person }: { person: Practitioner }) {
+ const badged = hasVerifiedBadge(person.credentials);
+ const [copied, setCopied] = useState(false);
+
+ /* The real product URL, not this prototype's path — the point of the button is
+ to show what a practitioner would paste into an application. Hardcoded here
+ because `site.ts` holds no canonical origin yet; it belongs there, added
+ with the real `/p/` route rather than for a prototype. */
+ const shareUrl = `https://bluehex.au${profilePath(person)}`;
+
+ const copy = () => {
+ void navigator.clipboard?.writeText(shareUrl);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ };
+
+ return (
+ /* A white card, because `bg-page` is a warm off-white and body copy at
+ `text-t-muted` on it reads as grey on grey. */
+
+
+ {badged ? (
+
+ ✓ Verified by Bluehex
+
+ ) : (
+
+ Self-listed
+
+ )}
+
+
+
+
+
{person.name}
+ {person.headline ? (
+
{person.headline}
+ ) : null}
+ {person.location ?
{person.location}
: null}
+
+ {person.bio ? (
+
{person.bio}
+ ) : null}
+
+
+
+
+ Credentials
+
+
+ {badged
+ ? "Opened and read by a human at Bluehex"
+ : "Not all of these have been checked"}
+
+ {credential.earnedAt ? "Certificate not published." : "Nothing to show yet."}
+
+ )}
+
+
+ ))}
+ {person.credentials.length === 0 ? (
+
+ No credentials listed — here to be findable, not to be certified.
+
+ ) : null}
+
+
+
+ {person.focus.length > 0 ? (
+
+ {person.focus.map((item) => (
+ {item}
+ ))}
+
+ ) : null}
+
+ {/* The id, not the name — see the comment in `contact/page.tsx`. The
+ banner will not appear from here, because these are fixture people and
+ `practitioners` is empty; wiring the fixture into a production lookup
+ to make a drawing look complete would be the wrong trade. */}
+
+ Enquire about {person.name.split(" ")[0]}
+
+
+ );
+}
diff --git a/src/components/practitioner-directory.tsx b/src/components/practitioner-directory.tsx
index 023409b..874a705 100644
--- a/src/components/practitioner-directory.tsx
+++ b/src/components/practitioner-directory.tsx
@@ -1,11 +1,13 @@
"use client";
+import Link from "next/link";
import { useId, useMemo, useRef, useState } from "react";
import { Close, Search, Sparkle } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
import {
countryName,
hasVerifiedBadge,
+ profilePath,
type Credential,
type Practitioner,
} from "@/lib/practitioners";
@@ -396,15 +398,29 @@ function PractitionerRow({ person }: { person: Practitioner }) {
))}
- {/* Enquiries go through Bluehex: no address on the row, and the link
- carries who the enquiry is about so the form can say so. */}
- `, so the navigation is soft and the route
+ is prefetched. Worth knowing if an overlay is ever attempted here
+ again: route interception applies to soft navigation *only*, so an
+ anchor silently disables it and full-page-loads every profile, and the
+ symptom looks exactly like a misconfigured interceptor. One was built
+ and cut — see the prototype's NOTES.md before re-attempting it.
+
+ NOTE: `/p/` does not exist yet. The directory ships empty, so no row
+ and no link renders today — but the profile route has to land before
+ the first practitioner is added, or this is a 404 and enquiries have no
+ path at all. */}
+
- Enquire
- about {person.name}
-
+ View profile
+ for {person.name}
+
>
);
}
diff --git a/src/lib/practitioners.ts b/src/lib/practitioners.ts
index 6c48854..61bf672 100644
--- a/src/lib/practitioners.ts
+++ b/src/lib/practitioners.ts
@@ -92,6 +92,30 @@ export function hasVerifiedBadge(credentials: Credential[]) {
return earned.length > 0 && earned.every((credential) => credential.verified);
}
+/**
+ * Where a profile lives.
+ *
+ * `mara-ellison-9f3c1a`: the short id is what resolves and the slug is
+ * decoration, so a rename changes the URL without breaking the old one — the
+ * route reads only the trailing id and serves a canonical redirect when the
+ * slug no longer matches. Readable enough to paste into a job application,
+ * which is the reason a profile has a URL at all.
+ *
+ * The id is the first six characters of the row's uuid. It must never be
+ * derived from the name: hashing the name would move the id whenever the name
+ * changed, which is the exact failure this scheme exists to prevent.
+ */
+export function profilePath(person: Pick) {
+ const slug = person.name
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/gu, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+
+ return `/p/${slug}-${person.id.slice(0, 6)}`;
+}
+
/* `Intl` already ships every country name, so a lookup table here would be a
few kilobytes of data to maintain for no gain. Built once, not per render. */
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
From 4ef5ef46306e992d5150cd91b90928c5e2846f12 Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:42:39 +1000
Subject: [PATCH 4/9] Resolve /p/ against the roster, not a prototype fixture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`/p/[handle]` imported `launchPopulation` from `src/app/prototype/directory/fixtures`, which does not exist on this branch — it arrives two pull requests later. The branch did not build, and this is the one that merges to `main` first, so `main` would not have built either. `pnpm lint` passes on it, which is why three sessions missed it: lint is not a typecheck.
The import is the defect rather than the missing module. A production route reading a throwaway fixture is what AGENTS.md warns about, and the machinery around it was all there to keep that premise standing: a `NODE_ENV` guard, a `noindex`, a panel explaining that a real build would redirect, and a breadcrumb pointing back into `/prototype/`. Removing the premise removes the requirement, so those go with it rather than being carried.
The lookup now reads `practitioners`, which is empty and stays empty until real people are in it. Every handle 404s today. That is the same emptiness the directory renders its invitation card for.
Two things the removed panel was standing in for are now real. A non-canonical slug redirects to the canonical path instead of rendering both with an explanation, which is what `profilePath`'s own docstring already claimed happened. The share button's origin moves out of a hardcoded string and into `site.ts`, where that file's comment said it belonged once the route was real.
`pnpm build` and `pnpm lint` both pass on this branch standing alone, for the first time.
---
src/app/p/[handle]/page.tsx | 47 +++++++++----------------------
src/app/p/_lib/handles.ts | 18 ++++++------
src/app/p/_lib/profile-detail.tsx | 10 +++----
src/lib/site.ts | 4 +++
4 files changed, 32 insertions(+), 47 deletions(-)
diff --git a/src/app/p/[handle]/page.tsx b/src/app/p/[handle]/page.tsx
index 8f1c115..d411fb8 100644
--- a/src/app/p/[handle]/page.tsx
+++ b/src/app/p/[handle]/page.tsx
@@ -1,31 +1,26 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { notFound } from "next/navigation";
+import { notFound, redirect } from "next/navigation";
import { profilePath } from "@/lib/practitioners";
import { findByHandle } from "../_lib/handles";
import { ProfileDetail } from "../_lib/profile-detail";
/**
- * A profile at its real URL. **Never production** — the guard below 404s it, so
- * production behaviour is unchanged from having no route here at all.
+ * A profile at its real URL.
*
- * It sits at `/p/` rather than under `/prototype/` on purpose. The directory
- * links here from production code (`profilePath` in `@/lib/practitioners`), so
- * a prototype at a different path would need the link faked, and faking the one
- * thing the design is built around — that the URL is real and shareable — would
- * make the prototype prove nothing. This is also the file the real page
- * replaces, which is the right shape for a drawing that is meant to be thrown
- * away from underneath.
+ * It sits at `/p/` rather than under `/prototype/` because the directory links
+ * here from production code (`profilePath` in `@/lib/practitioners`), and the
+ * whole point of a profile having a URL is that the URL is real and shareable.
*
- * What makes it a drawing is the data: it reads the throwaway fixture in
- * `@/app/prototype/directory/fixtures`, which is a deliberately awkward import.
- * A page under `/p/` reaching into `/prototype/` should look wrong, because it
- * is the part that must not survive.
+ * Only the trailing short id resolves; the slug is decoration. A request whose
+ * slug no longer matches is redirected to the canonical path rather than served
+ * in both places, which is what keeps a link alive across a rename without
+ * splitting the profile across two URLs.
*
* Every arrival renders this — clicked from the directory, pasted from a CV, or
* found in search. An earlier version intercepted the click into a drawer over
* the directory so the visitor kept their search context; that was cut. See
- * NOTES.md.
+ * `src/app/prototype/directory/NOTES.md`.
*/
export async function generateMetadata({
@@ -35,43 +30,27 @@ export async function generateMetadata({
return {
title: person ? `${person.name} — Bluehex` : "Profile",
- /* Noindex is a property of the prototype, not of the design. The real page
- is indexable on purpose — organic search is a third of why it has a URL. */
- robots: { index: false, follow: false },
};
}
export default async function ProfilePage({ params }: PageProps<"/p/[handle]">) {
- if (process.env.NODE_ENV === "production") notFound();
-
const handle = (await params).handle;
const person = findByHandle(handle);
if (!person) notFound();
- const canonical = profilePath(person).replace("/p/", "");
+ const canonical = profilePath(person);
+ if (`/p/${handle}` !== canonical) redirect(canonical);
return (
-
+
Directory
{" "}
/ {person.name}
-
- {handle !== canonical ? (
-
-
This is not the canonical URL
-
- You asked for {handle} and the canonical one is{" "}
- {canonical}. It resolved anyway, because only the trailing short
- id is read — which is how a rename keeps old links alive. A real build would
- redirect here rather than serve both.
-
-
- ) : null}
);
}
diff --git a/src/app/p/_lib/handles.ts b/src/app/p/_lib/handles.ts
index ab7a527..ec8bd22 100644
--- a/src/app/p/_lib/handles.ts
+++ b/src/app/p/_lib/handles.ts
@@ -1,10 +1,8 @@
/**
* Resolving a profile handle back to a profile.
*
- * The *generating* half of this lives in `@/lib/practitioners` as
- * `profilePath`, because the directory needs it to render links and that is
- * production code. Only the lookup is here, and only because what it looks up
- * is a throwaway fixture — a real build resolves against the database.
+ * The *generating* half of this lives in `@/lib/practitioners` as `profilePath`,
+ * because the directory needs it to render links. Only the lookup is here.
*
* There must be exactly one scheme. An earlier version of this file had its own
* `profileHandle` that hashed the *name* into a short id, which disagreed with
@@ -14,14 +12,18 @@
*
* The lookup reads only the trailing short id and ignores the slug, which is
* what makes a URL survive a rename — `/p/mara-ellison-9f3c1a` and
- * `/p/her-new-name-9f3c1a` are the same profile, and a real build would notice
- * the mismatch and serve a canonical redirect.
+ * `/p/her-new-name-9f3c1a` are the same profile. The route redirects a
+ * non-canonical slug to the canonical one rather than serving both.
+ *
+ * It resolves against `practitioners`, which is empty and stays empty until real
+ * people are in it — so every handle 404s today. That is the same emptiness the
+ * directory renders its invitation card for, not a missing case.
*/
-import { launchPopulation } from "@/app/prototype/directory/fixtures";
+import { practitioners } from "@/lib/practitioners";
export function findByHandle(handle: string) {
const id = handle.split("-").at(-1);
if (!id) return null;
- return launchPopulation.find((person) => person.id.slice(0, 6) === id) ?? null;
+ return practitioners.find((person) => person.id.slice(0, 6) === id) ?? null;
}
diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx
index a528983..35194bf 100644
--- a/src/app/p/_lib/profile-detail.tsx
+++ b/src/app/p/_lib/profile-detail.tsx
@@ -17,17 +17,17 @@
import { useState } from "react";
import { Badge } from "@/components/ui";
import { hasVerifiedBadge, profilePath, type Practitioner } from "@/lib/practitioners";
+import { site } from "@/lib/site";
import { CredentialMark, earnedLabel } from "./credential-mark";
export function ProfileDetail({ person }: { person: Practitioner }) {
const badged = hasVerifiedBadge(person.credentials);
const [copied, setCopied] = useState(false);
- /* The real product URL, not this prototype's path — the point of the button is
- to show what a practitioner would paste into an application. Hardcoded here
- because `site.ts` holds no canonical origin yet; it belongs there, added
- with the real `/p/` route rather than for a prototype. */
- const shareUrl = `https://bluehex.au${profilePath(person)}`;
+ /* Absolute, because the point of the button is what a practitioner pastes
+ into an application. The origin lives in `site.ts` with the rest of the
+ site-wide facts rather than being spelled out here. */
+ const shareUrl = `${site.origin}${profilePath(person)}`;
const copy = () => {
void navigator.clipboard?.writeText(shareUrl);
diff --git a/src/lib/site.ts b/src/lib/site.ts
index 8434e83..1205019 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -7,6 +7,10 @@
export const site = {
name: "Bluehex",
caption: "A Code.Sydney company",
+ /* Canonical origin, for the places that need an absolute URL rather than a
+ path — the share button on a profile today, metadata and sitemaps later.
+ No trailing slash; every path this is joined with starts with one. */
+ origin: "https://bluehex.au",
tagline:
"Claude practitioners from the Code.Sydney community. Find one, hire one.",
/* Contact details carried over from the Code.Sydney site — Code.Sydney Pty
From d66112c16599539b0c8ecaf55244478cd1a4065f Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:51:25 +1000
Subject: [PATCH 5/9] Draw a credential mark from one component, not two
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`earnedLabel`, `CredentialMark` and the tick path were defined twice by this branch — once in `practitioner-directory.tsx` for the roster row and once in `src/app/p/_lib/credential-mark.tsx` for the profile page. They had already disagreed inside the commit that introduced them: `month: "short"` against `month: "long"`, so the same credential read "Earned Aug 2026" on the roster and "Earned August 2026" on its own page.
The date format is cosmetic; the mark is not. It carries the three-state distinction between working towards, earned but unchecked, and verified, including the screen-reader text that states it — which per AGENTS.md is the product. Two files stating that with nothing keeping them in agreement is the defect, and the drift was the demonstration.
The copy existed because production code cannot import from a route's `_lib`, which is the right instinct about the direction of the dependency. Hoisting it to `src/components/credential-mark.tsx` fixes the direction and the duplication together, and both surfaces now import it. The shared `earnedLabel` keeps the short month, because the roster is the surface with the narrower column and it reads correctly on the page too.
---
src/app/p/_lib/credential-mark.tsx | 57 ----------------
src/app/p/_lib/profile-detail.tsx | 2 +-
src/components/credential-mark.tsx | 82 +++++++++++++++++++++++
src/components/practitioner-directory.tsx | 69 +------------------
4 files changed, 85 insertions(+), 125 deletions(-)
delete mode 100644 src/app/p/_lib/credential-mark.tsx
create mode 100644 src/components/credential-mark.tsx
diff --git a/src/app/p/_lib/credential-mark.tsx b/src/app/p/_lib/credential-mark.tsx
deleted file mode 100644
index 845b084..0000000
--- a/src/app/p/_lib/credential-mark.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * The credential furniture the drawer and the page both use.
- *
- * Trimmed when this surface settled: the variant fixtures and the identity
- * panel are gone, because the identity question they existed to force has an
- * answer and it now lives in `handles.ts` where the code that implements it is.
- */
-
-import type { Credential } from "@/lib/practitioners";
-
-export function earnedLabel(credential: Credential) {
- if (!credential.earnedAt) return "Working towards";
- const date = new Date(`${credential.earnedAt}T00:00:00Z`);
- return `Earned ${date.toLocaleDateString("en-AU", {
- month: "long",
- year: "numeric",
- timeZone: "UTC",
- })}`;
-}
-
-/**
- * The three states a credential can be in, as one mark: working towards, earned
- * but not yet checked, and verified. Distinguished by shape rather than by
- * colour alone, and each carries its own screen-reader text — the difference
- * between the second and third is the entire product.
- */
-export function CredentialMark({ credential }: { credential: Credential }) {
- if (!credential.earnedAt) {
- return (
-
- Working towards.
-
- );
- }
- if (!credential.verified) {
- return (
-
-
- Earned, not yet checked by Bluehex.
-
- );
- }
- return (
-
-
- Verified by Bluehex.
-
- );
-}
diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx
index 35194bf..6612af6 100644
--- a/src/app/p/_lib/profile-detail.tsx
+++ b/src/app/p/_lib/profile-detail.tsx
@@ -15,10 +15,10 @@
*/
import { useState } from "react";
+import { CredentialMark, earnedLabel } from "@/components/credential-mark";
import { Badge } from "@/components/ui";
import { hasVerifiedBadge, profilePath, type Practitioner } from "@/lib/practitioners";
import { site } from "@/lib/site";
-import { CredentialMark, earnedLabel } from "./credential-mark";
export function ProfileDetail({ person }: { person: Practitioner }) {
const badged = hasVerifiedBadge(person.credentials);
diff --git a/src/components/credential-mark.tsx b/src/components/credential-mark.tsx
new file mode 100644
index 0000000..826c09b
--- /dev/null
+++ b/src/components/credential-mark.tsx
@@ -0,0 +1,82 @@
+/**
+ * How a credential's state is drawn, wherever it is drawn.
+ *
+ * Two surfaces render credentials — the roster row and the profile page — and
+ * the three-state distinction they carry is the product itself, so it is stated
+ * once here rather than once per surface. It lived in both for a commit and had
+ * already disagreed with itself about the date format; the states are what
+ * cannot be allowed to drift, and the only way to guarantee that is one copy.
+ *
+ * In `src/components/` rather than beside either surface because the dependency
+ * must not point from production code into a route's `_lib`.
+ */
+
+import type { Credential } from "@/lib/practitioners";
+
+/** `earnedAt` is a date, not a timestamp — read and formatted as one. */
+export function earnedLabel(credential: Credential) {
+ if (!credential.earnedAt) return "Working towards";
+ const date = new Date(`${credential.earnedAt}T00:00:00Z`);
+ return `Earned ${date.toLocaleDateString("en-AU", {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ })}`;
+}
+
+/**
+ * The per-credential state, which is where all the nuance lives — the profile
+ * badge stays binary and everything else is said here. Three states, and they
+ * have to be distinguishable at a glance down a column:
+ *
+ * verified — a human at Bluehex read the evidence
+ * earned — claimed, not yet checked
+ * towards — no `earnedAt`; unverifiable, and outside the badge rollup
+ *
+ * Distinguished by shape rather than by colour alone, and each carries its own
+ * screen-reader text — the difference between the second and third is the
+ * entire product.
+ */
+export function CredentialMark({ credential }: { credential: Credential }) {
+ if (!credential.earnedAt) {
+ return (
+
+ Working towards.
+
+ );
+ }
+
+ if (!credential.verified) {
+ return (
+
+
+ Earned, not yet checked by Bluehex.
+
+ );
+ }
+
+ return (
+
+
+ Verified by Bluehex.
+
+ );
+}
+
+/* Exported because the roster's profile-level badge draws the same tick, and a
+ second copy of the path data is how the marks drifted the first time. On a
+ 10-unit grid rather than the 24 the rest of `icons.tsx` uses, because it is
+ only ever drawn at 10px inside a 16px dot. */
+export function Tick({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/practitioner-directory.tsx b/src/components/practitioner-directory.tsx
index 874a705..c2c98a1 100644
--- a/src/components/practitioner-directory.tsx
+++ b/src/components/practitioner-directory.tsx
@@ -2,15 +2,10 @@
import Link from "next/link";
import { useId, useMemo, useRef, useState } from "react";
+import { CredentialMark, Tick, earnedLabel } from "@/components/credential-mark";
import { Close, Search, Sparkle } from "@/components/icons";
import { Badge, Card } from "@/components/ui";
-import {
- countryName,
- hasVerifiedBadge,
- profilePath,
- type Credential,
- type Practitioner,
-} from "@/lib/practitioners";
+import { countryName, hasVerifiedBadge, profilePath, type Practitioner } from "@/lib/practitioners";
/**
* The practitioner directory: a search box, filters, and a roster of profiles
@@ -424,63 +419,3 @@ function PractitionerRow({ person }: { person: Practitioner }) {
>
);
}
-
-/** `earnedAt` is a date, not a timestamp — read and formatted as one. */
-function earnedLabel(credential: Credential) {
- if (!credential.earnedAt) return "Working towards";
- const date = new Date(`${credential.earnedAt}T00:00:00Z`);
- return `Earned ${date.toLocaleDateString("en-AU", {
- month: "short",
- year: "numeric",
- timeZone: "UTC",
- })}`;
-}
-
-/**
- * The per-credential state, which is where all the nuance lives — the profile
- * badge stays binary and everything else is said here. Three states, and they
- * have to be distinguishable at a glance down a column:
- *
- * verified — a human at Bluehex read the evidence
- * earned — claimed, not yet checked
- * towards — no `earnedAt`; unverifiable, and outside the badge rollup
- */
-function CredentialMark({ credential }: { credential: Credential }) {
- if (!credential.earnedAt) {
- return (
-
- Working towards.
-
- );
- }
-
- if (!credential.verified) {
- return (
-
-
- Earned, not yet checked by Bluehex.
-
- );
- }
-
- return (
-
-
- Verified by Bluehex.
-
- );
-}
-
-function Tick({ className = "" }: { className?: string }) {
- return (
-
- );
-}
From 5e0439989f01914c8590a6f4a9fd60bd3174e2f7 Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:51:56 +1000
Subject: [PATCH 6/9] Say Verified on the roster badge, and let the search find
a country
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two things the roster got wrong about words the rest of the page defines.
The badge said "Bluehex checked these" on a page whose own paragraph defines **Verified** as "Bluehex has checked that credential against the certificate that issued it", whose filter chip reads "Verified only", and whose profile page says "Verified by Bluehex". A visitor filtering by "Verified only" got back rows on which nothing was labelled verified and had to infer the two phrases were one claim. AGENTS.md treats the wording as the value proposition rather than as copy, so the roster was the surface that diverged from the standard, not the standard. "Verified by Bluehex" keeps the badge attached to the credentials it sits with, which is the load-bearing part.
The search index had no country in it, so the search box and the Location filter disagreed about the same fact: typing "Australia" matched nobody while the chip built from that person's `countryCode` selected them. Worse than useless, because `matchesQuery` requires every term to hit — adding a country word to an otherwise good query made results vanish rather than narrow. The country name goes into the haystack; the raw code stays out, since two-letter queries would hit more than they were aimed at.
The note claiming `/p/` does not exist goes with them. It does exist, as of the previous commit, and so does the link's destination.
---
src/components/practitioner-directory.tsx | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/components/practitioner-directory.tsx b/src/components/practitioner-directory.tsx
index c2c98a1..708dba7 100644
--- a/src/components/practitioner-directory.tsx
+++ b/src/components/practitioner-directory.tsx
@@ -35,12 +35,22 @@ import { countryName, hasVerifiedBadge, profilePath, type Practitioner } from "@
* "Verified only" filter computes it too.
*/
-/** Every field a query is matched against, flattened once per practitioner. */
+/**
+ * Every field a query is matched against, flattened once per practitioner.
+ *
+ * The country goes in as its *name*, because that is the word a visitor types
+ * and the same word the Location chips are labelled with. Leaving it out let
+ * the search box and the filter disagree about one fact: "Australia" matched
+ * nobody while the chip built from the same `countryCode` selected them. The
+ * raw code is deliberately not indexed — two-letter queries would hit far more
+ * than they were aimed at.
+ */
function searchIndex(person: Practitioner) {
return [
person.name,
person.headline ?? "",
person.location ?? "",
+ person.countryCode ? countryName(person.countryCode) : "",
person.bio ?? "",
...person.focus,
...person.credentials.flatMap((credential) => [credential.label, credential.source]),
@@ -342,7 +352,7 @@ function PractitionerRow({ person }: { person: Practitioner }) {
{hasVerifiedBadge(person.credentials) ? (
- Bluehex checked these
+ Verified by Bluehex
) : null}
@@ -403,12 +413,7 @@ function PractitionerRow({ person }: { person: Practitioner }) {
again: route interception applies to soft navigation *only*, so an
anchor silently disables it and full-page-loads every profile, and the
symptom looks exactly like a misconfigured interceptor. One was built
- and cut — see the prototype's NOTES.md before re-attempting it.
-
- NOTE: `/p/` does not exist yet. The directory ships empty, so no row
- and no link renders today — but the profile route has to land before
- the first practitioner is added, or this is a 404 and enquiries have no
- path at all. */}
+ and cut for that reason among others. */}
Date: Sat, 15 Aug 2026 18:52:39 +1000
Subject: [PATCH 7/9] Stop a profile URL and a country chip rendering a wrong
answer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two independent defects in the pair of functions this branch added.
`profilePath` returned `/p/-9f3c1a` for any name left with no ASCII residue after the diacritic strip — "李雷" and "Ω" both slug to the empty string, where "José Ávila" survives as "jose-avila". A leading bare hyphen where the readable half should be undercuts the whole reason the docstring gives for having a slug, which is that the URL is readable enough to paste into a job application. Not hypothetical for this directory in particular: the location filter added on this same branch exists because practitioners are expected outside Australia. The slug and its separator are now dropped together, giving `/p/9f3c1a`, which `findByHandle` resolves unchanged.
`countryName` promised to fall back to the code for an unknown region, but `Intl.DisplayNames` fails silently rather than throwing on the input most likely to arrive. Confirmed on Node 24.19: `of("AU")` gives "Australia", `of("au")` gives back `"au"`, and only a structurally invalid code such as `of("usa")` throws. So the `catch` never covered a lowercase code, and one would have rendered a filter chip reading `au`. Uppercasing before the lookup closes it; a check constraint on the column belongs with #53, and the docstring now says so.
---
src/lib/practitioners.ts | 28 +++++++++++++++++++++++++---
1 file changed, 25 insertions(+), 3 deletions(-)
diff --git a/src/lib/practitioners.ts b/src/lib/practitioners.ts
index 61bf672..8f4c7b6 100644
--- a/src/lib/practitioners.ts
+++ b/src/lib/practitioners.ts
@@ -104,6 +104,16 @@ export function hasVerifiedBadge(credentials: Credential[]) {
* The id is the first six characters of the row's uuid. It must never be
* derived from the name: hashing the name would move the id whenever the name
* changed, which is the exact failure this scheme exists to prevent.
+ *
+ * Nothing yet guarantees those six characters are unique, and `findByHandle`
+ * returns the first row that matches them \u2014 so a collision serves the wrong
+ * profile rather than a 404. The enforcement has to be in the schema, and is
+ * open on the review of #63.
+ *
+ * The slug is dropped rather than left empty when a name has no ASCII residue
+ * \u2014 every character of "\u674e\u96f7" is stripped by the transliteration \u2014 because
+ * `/p/-9f3c1a` leads with a bare hyphen where the readable half is meant to be.
+ * `/p/9f3c1a` resolves identically and does not look broken.
*/
export function profilePath(person: Pick) {
const slug = person.name
@@ -113,17 +123,29 @@ export function profilePath(person: Pick) {
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
- return `/p/${slug}-${person.id.slice(0, 6)}`;
+ return `/p/${slug ? `${slug}-` : ""}${person.id.slice(0, 6)}`;
}
/* `Intl` already ships every country name, so a lookup table here would be a
few kilobytes of data to maintain for no gain. Built once, not per render. */
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
-/** "AU" → "Australia". Falls back to the code if it is not a known region. */
+/**
+ * "AU" → "Australia", and "au" → "Australia" too.
+ *
+ * The uppercasing is not tidiness. `Intl.DisplayNames` is case-sensitive on the
+ * region code and fails *silently* rather than throwing: `of("au")` returns
+ * `"au"` straight back, so the `catch` never fires and a lowercase code renders
+ * as a filter chip labelled `au`. Only a structurally invalid code such as
+ * `"usa"` throws, which is the narrow case the `catch` actually covers.
+ *
+ * A `check (country_code ~ '^[A-Z]{2}$')` on the column when #53 writes the
+ * table is worth pairing with this, so normalising here is not the only thing
+ * between the database and a chip labelled `au`.
+ */
export function countryName(code: string) {
try {
- return regionNames.of(code) ?? code;
+ return regionNames.of(code.toUpperCase()) ?? code;
} catch {
return code;
}
From 3b1a33b9ef1b4c72880db234d2b88012d8c16dce Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:53:45 +1000
Subject: [PATCH 8/9] Percent-encode the mailto query, as RFC 6068 wants
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`URLSearchParams.toString()` serialises as `application/x-www-form-urlencoded`, which writes a space as `+`. A mailto query is not form encoded: RFC 6068 specifies percent-encoding, in which `+` is a literal plus. Clients split on it — the ones that follow the RFC open a compose window reading "Enquiry+about+Mara+Ellison", the ones that reuse form decoding show spaces.
The line is pre-existing, but this branch rewrites it and adds a third value that passes through it, so it both widens the exposure and makes the mangling more visible. A practitioner's name is the last thing that should reach Bluehex's inbox looking like that.
Replacing every remaining `+` with `%20` is safe because a plus a visitor actually typed is already `%2B` by that point — confirmed by round-tripping a body containing "C++ work", which survives. `URLSearchParams` still builds the query, so it still percent-encodes `&`, `?`, CR and LF and no field value can introduce a second mailto header.
---
src/app/contact/contact-form.tsx | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/app/contact/contact-form.tsx b/src/app/contact/contact-form.tsx
index aaf4b58..859f612 100644
--- a/src/app/contact/contact-form.tsx
+++ b/src/app/contact/contact-form.tsx
@@ -44,7 +44,16 @@ export function ContactForm({ email, about }: { email: string; about?: string })
body,
});
- window.location.href = `mailto:${email}?${query}`;
+ /* `URLSearchParams.toString()` serialises as `application/x-www-form-
+ urlencoded`, which writes a space as `+`. A mailto query is not form
+ encoded — RFC 6068 wants percent-encoding, where `+` is a literal plus —
+ so clients split on it and the strict ones open a compose window reading
+ "Enquiry+about+Mara+Ellison". A literal plus in a field is already
+ `%2B` by this point, so replacing every remaining `+` is safe.
+
+ `URLSearchParams` is still what builds the query: it percent-encodes `&`,
+ `?`, CR and LF, so no field value can inject a second mailto header. */
+ window.location.href = `mailto:${email}?${query.toString().replace(/\+/g, "%20")}`;
};
return (
From ab4fe0361fcebb94135d4459c11f238310b21148 Mon Sep 17 00:00:00 2001
From: David Taing
Date: Sat, 15 Aug 2026 18:53:45 +1000
Subject: [PATCH 9/9] Only say "Link copied" when something was copied
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The button set the label unconditionally. `navigator.clipboard` is undefined on any non-secure origin — a dev server reached over a LAN address rather than localhost is the everyday case — and the optional chaining then short-circuited straight into `setCopied(true)`; where the object does exist, `writeText` returns a promise that rejects on a denied permission or an unfocused document, and the `void` discarded the rejection along with it. Both paths reported a copy that had not happened.
Awaiting the write puts the confirmation behind the fact, and a failure leaves the label alone. This was ranked a nit against a file that described itself as throwaway, and it is worth more than that now the route is real: pasting a profile link into an application is the reason the route exists, so a button that lies about having copied breaks precisely the flow it is there for.
Three comments still described the prototype that is gone. Two pointed at a `NOTES.md` that does not exist on this branch, and one said the enquiry banner would never appear because "these are fixture people" — the page resolves against the roster now, so it will. They say what they mean instead of deferring to a file that is not here.
---
src/app/p/[handle]/page.tsx | 5 +++--
src/app/p/_lib/profile-detail.tsx | 32 +++++++++++++++++++++----------
2 files changed, 25 insertions(+), 12 deletions(-)
diff --git a/src/app/p/[handle]/page.tsx b/src/app/p/[handle]/page.tsx
index d411fb8..c298095 100644
--- a/src/app/p/[handle]/page.tsx
+++ b/src/app/p/[handle]/page.tsx
@@ -19,8 +19,9 @@ import { ProfileDetail } from "../_lib/profile-detail";
*
* Every arrival renders this — clicked from the directory, pasted from a CV, or
* found in search. An earlier version intercepted the click into a drawer over
- * the directory so the visitor kept their search context; that was cut. See
- * `src/app/prototype/directory/NOTES.md`.
+ * the directory so the visitor kept their search context; that was cut, because
+ * interception applies to soft navigation only and a link pasted from anywhere
+ * else is a cold arrival at this page regardless.
*/
export async function generateMetadata({
diff --git a/src/app/p/_lib/profile-detail.tsx b/src/app/p/_lib/profile-detail.tsx
index 6612af6..e6779bc 100644
--- a/src/app/p/_lib/profile-detail.tsx
+++ b/src/app/p/_lib/profile-detail.tsx
@@ -5,8 +5,8 @@
*
* It rendered in two containers for a while — a drawer over the directory on a
* click, a page on a cold arrival, one component behind both. The drawer was cut
- * along with the route interception that produced it; see NOTES.md for why. What
- * is left is the page, which was always the half that had to work.
+ * along with the route interception that produced it. What is left is the page,
+ * which was always the half that had to work.
*
* What is here and not on the roster row: the bio, the earned dates, and the
* credential sources. Three fields. The page is not justified by that depth —
@@ -29,10 +29,23 @@ export function ProfileDetail({ person }: { person: Practitioner }) {
site-wide facts rather than being spelled out here. */
const shareUrl = `${site.origin}${profilePath(person)}`;
- const copy = () => {
- void navigator.clipboard?.writeText(shareUrl);
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1600);
+ /* Confirm after the fact, not before it. `navigator.clipboard` is undefined
+ on any non-secure origin — a dev server reached over a LAN address rather
+ than localhost — and where it does exist `writeText` rejects on a denied
+ permission or an unfocused document. Optional chaining and a discarded
+ promise both used to reach `setCopied(true)` regardless, so the button
+ claimed a copy that had not happened. Pasting a profile link into an
+ application is the reason this route exists, which makes that the one lie
+ it cannot afford. */
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ } catch {
+ /* Leave the label alone — nothing was copied, and the URL is in the
+ address bar for anyone who needs it. */
+ }
};
return (
@@ -123,10 +136,9 @@ export function ProfileDetail({ person }: { person: Practitioner }) {
) : null}
- {/* The id, not the name — see the comment in `contact/page.tsx`. The
- banner will not appear from here, because these are fixture people and
- `practitioners` is empty; wiring the fixture into a production lookup
- to make a drawing look complete would be the wrong trade. */}
+ {/* The id, not the name — see the comment in `contact/page.tsx`, which
+ resolves it back to a name against the same roster this page was
+ resolved from. */}