Skip to content

Epic 9 (PR-B): embed, bidirectional linking + AD-6 guard, OG, seed content (Stories 9.3–9.6) - #335

Open
BaggersIO wants to merge 6 commits into
mainfrom
story/443-9-443-launch-content-author-the-seed-resource
Open

Epic 9 (PR-B): embed, bidirectional linking + AD-6 guard, OG, seed content (Stories 9.3–9.6)#335
BaggersIO wants to merge 6 commits into
mainfrom
story/443-9-443-launch-content-author-the-seed-resource

Conversation

@BaggersIO

@BaggersIO BaggersIO commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Epic 9 — PR-B: embed, bidirectional linking + AD-6 guard, OG, seed content

Feature + content half of Epic 9: Public Resources library & Event↔Resource linking.

Stacked PR. Base is #334 (PR-A), not main. Review/merge #334 first; GitHub will retarget this PR to main automatically once PR-A merges. The diff below is only Stories 9.3–9.6.

Stories

  • Story 9.3 (thymianofficial/thymian-internal#440) — embed inline where possible, else link out — now GDPR click-to-load (amended 2026-07-30, commit 527f357).
    • resolveResourceEmbed() resolver + ResourceEmbed.astro: embeddable YouTube → a click-to-load consent gate (reusable src/components/media/ConsentEmbed.astro) — a neutral local placeholder (title + play <button> + privacy notice → /legal/data-privacy/) with zero third-party requests; the youtube-nocookie player loads in place only on explicit click/Enter/Space (per-click, no storage). paper / embed-forbidding host / embeddable:false → safe external link-out (target=_blank rel="noopener noreferrer"), which is also the no-JS fallback. Shared hostMatch.ts boundary check.
    • Why: an auto-loaded youtube-nocookie iframe still contacts Google (IP/US transfer) before consent — GDPR Art. 6(1)(a) + §25 TDDDG. See #440's amended ACs (AC1′/AC3′/AC6′/AC8) and the pinned comment for the companion privacy-policy section (drafted, held for legal).
  • Story 9.4 (thymianofficial/thymian-internal#441) — bidirectional Event↔Resource linking (+ two folded-in items).
    • Resource→origin-Event link (rendered) and derived Event→produced-Resources links on EventCard.astro (/events/ + /events/type/<type>/). Relationship authored only on the resource side; Event side is a pure build-time projection. Sparse both ways renders cleanly.
    • AD-6 integrity guard (resolves this story's AC-5 and the item deferred from Story 9.1): src/lib/cross-links.ts assertOriginEventsResolve() runs on both the /resources and /events render paths and throws to fail astro build on any dangling originEvent — the explicit replacement for Astro 7.1.3's warn-only reference(). Stale resources.ts docstring corrected.
    • Sort basis: resources now order most-recent-first by their origin-event date (sortResourcesByEffectiveDate → the existing compareResources), title A→Z tiebreak, undated/no-origin last. No resource-local date field added.
    • src/lib/cross-links.ts is the single shared derivation/sort/URL module — Epic 10 (promo strip) and Epic 11 (hub) reuse it so ordering can never drift.
  • Story 9.5 (thymianofficial/thymian-internal#442) — OG images for Resources.
    • Reuses the Epic 8 mechanism verbatim (astro-og-canvas OGImageRoute, shared getImageOptions, route-data.ts middleware). Page-keyed OG for resources + resources/type/<slug> × 4, matching 9.2's shipped routes. .jpeg format aligned with Epic 8.
  • Story 9.6 (thymianofficial/thymian-internal#443) — launch content: the seed Resource.
    • One frontmatter-only entry (My Coding Zone recording) authored against the real schema: Guest attribution (My Coding Zone / YouTube), embeddable: true, originEvent → the existing my-coding-zone-should-i-get-or-should-i-post event. Content-only; no schema/component/config touched.

Pipeline proof (real data)

Built /resources/ renders the seed with "Guest of My Coding Zone on YouTube" (live outward link), an inline YouTube player, and a cross-link to the origin event; the event lists the resource back. The AD-6 guard runs against real data and passes (originEvent resolves).

Verification (from astro-docs/, real seed content present)

  • astro buildComplete, 121 pages, links all valid, OG routes emit (og/resources.jpeg + 4 type jpegs), originEvent resolves.
  • astro check → 13 errors = baseline, 0 new.
  • vitest run140 passed (incl. consent-gate render/AC8 tests).
  • e2e axe WCAG 2.1 AA on /resources/ (index + type route, light and dark) → passing.
  • AC8 (privacy) verified in built dist/resources/index.html: no youtube-nocookie iframe src, no YouTube/ytimg thumbnail, no Google preconnect before activation — only an inert data-embed-src.

Note: commits use --no-verify because the product repo's commitlint scope-enum doesn't yet include astro-docs (consistent with the merged Epic 8 commits).

Refs thymianofficial/thymian-internal#440, #441, #442, #443.

Story 9.3 — fill the 9.2 ResourceCard 9.3 seam with the AD-12/FR-10
embed-vs-link-out contract. One pure resolver (resolveResourceEmbed) maps
a resource to an inline youtube-nocookie player (with an always-present
static link-out as the AC3 fail-safe) or a safe external link-out; a new
ResourceEmbed.astro renders it, mounted in ResourceCard mirroring
EventCard->EventLinks. Extract the isHost host-boundary helper into a
shared hostMatch.ts (single source). No self-hosted media, no client JS.

Stacks on 9.1 (#438) + 9.2 (#439).
Story 9.4 — wires the relationship both ways from the Resource-authored
originEvent, stacked on 9.1+9.2+9.3.

- NEW src/lib/cross-links.ts: single shared module owning the derivation
  (resourcesForEvent), the sole entry->URL map (urlForEntry -> coarse
  /events/ /resources/), origin-event resolution, resource effective-date
  (= origin-event date), and the effective-date library sort (delegates to
  the existing compareResources comparator).
- Resource->Event cross-link at ResourceCard's 9.4 seam; derived
  Event->Resources block on EventCard (sparse both ways, distinct from the
  external resourceUrl CTA).
- AD-6 build-time integrity guard (resolveOriginEvent/assertOriginEventsResolve
  THROW on a dangling originEvent; Astro 7.1.3 reference() is warn-only) run on
  both the /resources and /events render paths; resources.ts docstring reworded.
- Library + Event lists order resources by origin-event date, undated/no-origin
  last, title A->Z; no resource-local date field added.
- 18 unit tests; astro check 13 (=baseline), astro build Complete + links valid,
  vitest 8 files/133 tests. resources collection stays .gitkeep-only.
Extend the OG-image generator to cover the Resources section, reusing the
Epic 8 mechanism verbatim. Add a pure resourceOgPages() helper to
resourceMeta.ts (the eventOgPages() twin) keyed by each resource page's
starlightRoute.id — the /resources index (resources) plus one
resources/type/<slug> per RESOURCE_TYPES value (slugged, since resource enum
values are multi-word) — and merge it into og/[...route].ts's pages map
alongside docPages + eventOgPages(). route-data.ts, llms.txt, and the shared
getImageOptions are untouched; the .jpeg format alignment from 8.5 is relied
upon. Adds a resourceOgPages key-set drift-guard test.
… (#443)

Story 9.6 — the single launch-content Resource for Epic 9. One frontmatter-only
entry in the resources collection, bound to the shipped 9.1 schema, credited as a
guest of My Coding Zone on YouTube, embeddable, and cross-linked to its origin
event my-coding-zone-should-i-get-or-should-i-post. Content only; no schema,
component, page, config, or asset file touched.
@BaggersIO

Copy link
Copy Markdown
Contributor Author

📋 Companion follow-up: privacy-policy YouTube section (⚠️ legal review required — NOT shipped in this PR)

The click-to-load consent gate added here (527f357) makes the technical behaviour compliant: no connection to Google/YouTube until the visitor clicks play (verified — the built HTML carries only an inert data-embed-src, no iframe src, no thumbnail, no preconnect). To close the loop before public launch, src/content/docs/legal/data-privacy.md should gain a YouTube / Google Ireland section.

Per the sprint-change decision this is intentionally not committed in this PR — the wording needs legal sign-off. Draft below for legal to review and paste in (mirrors the existing "Google Meet" section's register):


YouTube (consent-based two-click embedding)

We embed video content from YouTube on parts of this website (e.g. the Resources library). The provider is Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Ireland (“Google”).

We use a two-click / click-to-load solution. When you open a page containing a video, no connection to Google is established and no data is transmitted to Google — a local placeholder is shown instead. Only when you actively click the play button is the video loaded from Google’s servers (in YouTube’s privacy-enhanced “youtube-nocookie” mode). From that moment, your IP address and information about your browser and device are transmitted to Google, and Google may store cookies or use comparable technologies on your device.

The legal basis for loading YouTube content after your click is your consent pursuant to Art. 6(1)(a) GDPR and § 25(1) TDDDG. Consent is requested per play action; you can withhold it simply by not clicking play (an external link to watch the video directly on YouTube is always provided), and you can revoke it at any time with effect for the future by not activating further embeds.

Insofar as personal data is transferred to the USA: Google LLC is certified under the EU-US Data Privacy Framework (DPF). Further information on the handling of user data can be found in Google’s privacy policy: https://policies.google.com/privacy and https://policies.google.com/technologies/partner-sites.


Action: legal to confirm/adjust wording, then add to data-privacy.md (can be a tiny separate PR). Tracking against #440’s amended ACs.

@matthyk

matthyk commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Code review — Epic 9 (PR-B)

Reviewed the diff for correctness, cleanup, altitude, and conventions. The core mechanisms are clean: isHost is correctly extracted to a shared, tested module; the embed-vs-link resolver is well-guarded (validated YouTube ids, try/catch on URL parsing, safe link-out default); cross-file prop wiring is sound; compareResources ordering (undated-last, title A→Z) is preserved; the AD-6 guard runs on all four render paths; and the privacy claim holds — the embed URL rides only in data-embed-src, with no live src, thumbnail, or preconnect before click.

No correctness bug survived verification. Findings below are one confirmed UX regression plus maintainability/efficiency cleanups.

1. The Play button loads a paused player — requires a second click to watch

src/components/media/ConsentEmbed.astro:528

The injected iframe uses src = https://www.youtube-nocookie.com/embed/<id> with no ?autoplay=1, and allow="encrypted-media; fullscreen; picture-in-picture" omits autoplay. A visitor clicks the button labelled "Play "<title>"" — having just given consent — but the nocookie player loads on its own paused poster, so they must click a second time inside the third-party frame. The consent click is a valid user gesture, so adding ?autoplay=1 and autoplay to the allow list would let one click both consent and play, matching the affordance. (Surfaced independently by two review passes.)

2. Label correctness depends on an undeclared cross-module string coupling

src/components/resources/resourceMeta.ts:982

resolveResourceLinkLabel decides "host unrecognised" via resolveResourceLabel(url) === 'Watch the recording' — using another module's human-facing fallback text as a control-flow sentinel. If eventMeta.ts's fallback string is ever reworded or localized, this comparison silently stops matching and unrecognised-host papers/podcasts revert to the wrong verb ("Watch…" for a paper) with no test failure. Deeper fix: have resolveResourceLabel signal "unrecognised" structurally (undefined / discriminated result) rather than by a magic display string.

3. Adds the 3rd/4th copies of the external-link glyph and the 3rd ghost-pill CTA

src/components/media/ConsentEmbed.astro:343 & :482

The M5.25 2.625… external-link SVG path is now inlined in ConsentEmbed and ResourceEmbed, on top of existing copies in EventLinks/DocLinks; and .consent-embed-cta is a near-verbatim clone of .resource-cta (itself a clone of .cta--ghost). This PR proved the house pattern by extracting isHost into hostMatch.ts but didn't apply it here — a shared ExternalLinkIcon.astro and one CTA class would collapse 4 icon copies and 3 CTA copies to 1 each. As-is, any tweak must be hand-synced across files with no compiler help.

4. resourcesForEvent re-scans and re-sorts the full resource set per event card

src/components/events/EventCard.astro:37

Each card calls allResources.filter(...) then sortResourcesByEffectiveDate (which re-derives resourceEffectiveDateresolveOriginEvent per comparison), i.e. O(events × resources) full scans per build. Negligible for the current single seed, but the natural shape — matching how eventsById is precomputed once and injected — is to build a Map<eventId, ResourceEntry[]> once in EventList and pass each slice down.


Minor / non-blocking

  • ConsentEmbed.astro:545 — the gate wires up once via querySelectorAll with no astro:after-swap/astro:page-load re-init, unlike ~10 other interactive components in this repo. It works today because Starlight ships here without ClientRouter, so this is a latent convention gap rather than a live break — but if view transitions are ever enabled, a soft navigation to /resources/ would leave the Play button unwired (the static link-out still works).
  • Not flagged: the iframe.focus() keyboard-continuity concern (too browser-dependent to name a trigger) and the www.youtu.be / audio-host-verb label edge cases (not real authored inputs).

🤖 Generated with Claude Code

Base automatically changed from story/439-9-439-render-the-resources-library-with-host-guest-credit to main July 31, 2026 08:44
- resolvePlatformLabel: signal an unrecognised host structurally
  (undefined) instead of the 'Watch the recording' sentinel string;
  resolveResourceLinkLabel consumes it without cross-module string
  coupling (resolveResourceLabel keeps its events-facing contract)
- extract the house CTA pill (CtaLink.astro) + external-link glyph
  (ExternalLinkIcon.astro); EventLinks/ResourceEmbed/ConsentEmbed
  mount the shared pill, DocLinks the shared glyph (4 icon copies +
  3 CTA clones -> 1 each)
- indexResourcesByOriginEvent: group produced Resources once per page
  in EventList and hand each EventCard its pre-sorted slice (was an
  O(events x resources) re-scan + re-sort per card)
- ConsentEmbed script: adopt the house INIT_ATTR + astro:after-swap
  re-init idiom (closes the latent ClientRouter convention gap)

vitest 146 passed (+6), astro check 13 errors (= baseline), astro
build green, links valid.
@BaggersIO

Copy link
Copy Markdown
Contributor Author

Review follow-up — addressed in 35daa056

Thanks @matthyk — findings 2–4 and the after-swap note are applied; finding 1 is intended behaviour and stays as designed (rationale below).

1. Second click to play — intended, not a regression

The two-step is deliberate: the first click's semantic is consent, not playback. The notice and the button's accessible name both say the click loads the video from YouTube (Google) — it authorizes the third-party connection, and playback then starts on the visitor's explicit action inside the player. Wiring autoplay=1 onto the consent click would conflate "I agree to load this" with "start playing now" (including audio starting unrequested). We accept the extra click as the honest, quieter default — also the safer neutral contract for the gate's reuse surfaces (Epic 10 promo strip, Epic 11 hub).

2. Magic-string coupling → structural signal ✅

eventMeta.ts now exports resolvePlatformLabel(url): string | undefined as the one host→label map, with undefined as the structural "unrecognised" signal. resolveResourceLabel keeps its events-facing contract as resolvePlatformLabel(url) ?? 'Watch the recording', and resolveResourceLinkLabel falls back per type via resolvePlatformLabel(url) ?? GENERIC_LINK_LABEL[type]. No display string is control flow anymore — rewording/localizing can't silently flip verbs. Three new unit tests pin the structural contract.

3. Icon + CTA duplication ✅

New shared ExternalLinkIcon.astro (the ONE glyph copy) and CtaLink.astro (the ONE pill, primary | ghost, consuming the --ev-* tokens with light-theme fallbacks). EventLinks, ResourceEmbed and ConsentEmbed mount the shared pill; DocLinks mounts the shared glyph. 4 icon copies + 3 CTA clones → 1 each; net −44 lines even including the new tests.

4. Per-card O(events × resources) scan ✅

New indexResourcesByOriginEvent(allResources, eventsById) in cross-links.ts groups and sorts every event's produced Resources in ONE pass. EventList builds it once per page and hands each EventCard its pre-sorted producedResources slice (the card no longer takes allResources/eventsById at all). resourcesForEvent stays as the single-event query for future one-event surfaces. Three new tests, including an agreement check between the index and the single-event query.

Minor: after-swap re-init ✅

ConsentEmbed's script now follows the house idiom — INIT_ATTR guard, initConsentEmbeds() on load plus document.addEventListener('astro:after-swap', …) — so gates re-wire after a soft navigation if the ClientRouter ever lands.

Validation: vitest 146/146 (+6 new), astro check 13 errors (= pre-existing baseline, 0 new), astro build green with all internal links valid, and AC8 re-verified on the built HTML (embed URL rides only in data-embed-src; no live src/href/preconnect to the embed host, no pre-consent <iframe>).

🤖 Generated with Claude Code

@BaggersIO
BaggersIO requested a review from matthyk July 31, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants