A profile page draws two buttons side by side: Enquire about <first name>, which goes to /contact and mails Bluehex, and Book a meeting, which opens person.bookingUrl in a new tab. They are styled as a primary and a secondary of the same thing, and nothing on the page says they are not. src/app/p/_lib/profile-detail.tsx:294-310.
They are not the same thing at all. One puts a human at Bluehex in the middle; the other hands the visitor to a scheduler Bluehex does not run, to book time Bluehex will not be party to, and there is no indication either that the click leaves the site or that what follows it is a direct engagement.
Why this is worth a ticket rather than a tooltip
docs/adr/0002-links-are-published-addresses-are-not.md decided this deliberately — "Bluehex declines to publish personal contact details; it does not decline to let people talk" — and recorded the operational half of the consequence: enquiries that go direct are enquiries Bluehex never sees. The visitor-facing half was never written. The person clicking is the one who most needs to know which side of that line they have just crossed, and they are the one the ADR does not address.
The badge sharpens it. Verified means Bluehex checked this person's credentials; it does not mean Bluehex arranged, vetted or stands behind the meeting. A booking button a few hundred pixels under that badge, in Bluehex's own chrome, is exactly where the two get conflated — and conflating them is a claim about Bluehex's involvement that Bluehex has not made.
Scope
An external-link affordance on the button. ArrowUpRight already exists in src/components/icons.tsx and /contact already uses it. Icon plus sr-only text naming the new tab, so it is not purely visual.
A line of disclaimer beside the button, rendered only when there is a booking link to disclaim — the button is already conditional on person.bookingUrl.
The copy
The sense to convey, as given:
This booking link takes you to the contractor's own booking page. By booking, you'll be redirected to the contractor's external scheduling page. Engagements outside Bluehex are between you and the contractor directly.
Two things to settle before it ships, which is most of why this is hitl:
- "Contractor" is not the word this project uses.
CONTEXT.md says practitioner, and the profile page already speaks of the person by first name. A second noun for the same person, introduced only in the disclaimer, reads as boilerplate pasted in from somewhere else — which undercuts the one line on the page that most needs to be believed.
- The first two sentences say the same thing twice. Recommended, keeping the meaning and the first name the page already uses: "Booking opens Mara's own scheduling page. Anything arranged there is between you and them, not through Bluehex." Adjust rather than treat as final — the wording is a judgement call about what Bluehex is asserting, not a copy edit.
Details that will bite
/contact's booking button must not get this. That one is Bluehex's own Google Calendar link (src/app/contact/page.tsx:13), and it is as external in hostname as any of these. The distinction the copy draws is whose calendar it is, not whether the host is ours — a disclaimer on Bluehex's own booking page would be false.
- It is not a link-safety fix. The
https_url domain constrains the scheme at the database, so javascript: never reaches an href, and rel="noopener noreferrer" is already set on both outbound links. Nothing here is exploitable today; what is missing is the visitor's expectation.
- Do not put it in
title. Invisible on touch, unreliable to screen readers, and gone the moment anyone reads the page on a phone — which is where a booking button gets pressed.
- Make it reusable, because three more of these are coming.
website_url, github_url and linkedin_url are in the model and granted to anon, and src/lib/directory-mapping.ts:189 notes that only bookingUrl is drawn so far. The affordance wants to be a small shared piece rather than JSX inlined at one call site; See the certificate (profile-detail.tsx:233) is a fourth outbound link that already exists and has the same gap.
- Check it against the published terms before wording it as a liability statement.
src/lib/site.ts links Code.Sydney's client terms and privacy policy, and a sentence on a profile page that contradicts either is worse than no sentence.
Done when
- A profile with a booking link shows that the button leaves Bluehex, in a way that survives a screen reader and a phone.
- The disclaimer names what Bluehex is and is not party to, in this project's vocabulary, and appears only where there is a booking link.
/contact's own booking button is untouched.
pnpm lint, pnpm test and pnpm test:e2e pass.
Not in scope
- Routing bookings through Bluehex, or any per-profile proxy. ADR 0002 deferred that deliberately; this ticket describes what already happens rather than changing it.
- Terms of service, or a site-wide legal page. This is one line about one action, next to that action.
- Drawing the other three profile links. They will want the same affordance, which is why it should be shared — but adding them is its own ticket.
A profile page draws two buttons side by side: Enquire about <first name>, which goes to
/contactand mails Bluehex, and Book a meeting, which opensperson.bookingUrlin a new tab. They are styled as a primary and a secondary of the same thing, and nothing on the page says they are not.src/app/p/_lib/profile-detail.tsx:294-310.They are not the same thing at all. One puts a human at Bluehex in the middle; the other hands the visitor to a scheduler Bluehex does not run, to book time Bluehex will not be party to, and there is no indication either that the click leaves the site or that what follows it is a direct engagement.
Why this is worth a ticket rather than a tooltip
docs/adr/0002-links-are-published-addresses-are-not.mddecided this deliberately — "Bluehex declines to publish personal contact details; it does not decline to let people talk" — and recorded the operational half of the consequence: enquiries that go direct are enquiries Bluehex never sees. The visitor-facing half was never written. The person clicking is the one who most needs to know which side of that line they have just crossed, and they are the one the ADR does not address.The badge sharpens it. Verified means Bluehex checked this person's credentials; it does not mean Bluehex arranged, vetted or stands behind the meeting. A booking button a few hundred pixels under that badge, in Bluehex's own chrome, is exactly where the two get conflated — and conflating them is a claim about Bluehex's involvement that Bluehex has not made.
Scope
An external-link affordance on the button.
ArrowUpRightalready exists insrc/components/icons.tsxand/contactalready uses it. Icon plussr-onlytext naming the new tab, so it is not purely visual.A line of disclaimer beside the button, rendered only when there is a booking link to disclaim — the button is already conditional on
person.bookingUrl.The copy
The sense to convey, as given:
Two things to settle before it ships, which is most of why this is
hitl:CONTEXT.mdsays practitioner, and the profile page already speaks of the person by first name. A second noun for the same person, introduced only in the disclaimer, reads as boilerplate pasted in from somewhere else — which undercuts the one line on the page that most needs to be believed.Details that will bite
/contact's booking button must not get this. That one is Bluehex's own Google Calendar link (src/app/contact/page.tsx:13), and it is as external in hostname as any of these. The distinction the copy draws is whose calendar it is, not whether the host is ours — a disclaimer on Bluehex's own booking page would be false.https_urldomain constrains the scheme at the database, sojavascript:never reaches anhref, andrel="noopener noreferrer"is already set on both outbound links. Nothing here is exploitable today; what is missing is the visitor's expectation.title. Invisible on touch, unreliable to screen readers, and gone the moment anyone reads the page on a phone — which is where a booking button gets pressed.website_url,github_urlandlinkedin_urlare in the model and granted toanon, andsrc/lib/directory-mapping.ts:189notes that onlybookingUrlis drawn so far. The affordance wants to be a small shared piece rather than JSX inlined at one call site; See the certificate (profile-detail.tsx:233) is a fourth outbound link that already exists and has the same gap.src/lib/site.tslinks Code.Sydney's client terms and privacy policy, and a sentence on a profile page that contradicts either is worse than no sentence.Done when
/contact's own booking button is untouched.pnpm lint,pnpm testandpnpm test:e2epass.Not in scope