# marcus-blog: how the site fits together

A blog of articles and video stories. Every post is a **Drupal Canvas page** built from
**SDC components**, publishable by hand in the Canvas editor or by an agent over **MCP**.

## Content model

| What | Where | Notes |
|---|---|---|
| Post / page | `canvas_page` (Canvas Page entity) | Fully free-form component tree. |
| Page kind | `field_page_kind` (post \| page) | Posts get the post frame, a `/blog/<title>` path and appear in listings. |
| Categories | `field_categories` → vocabulary `categories` | Tags on posts and cards link to `/blog?category=<term ID>` (the category filter on `/blog`). The old `/blog/category/<name>` and term pages redirect there (deploy hook `category_redirects`). |
| Authors | `field_authors` → vocabulary `authors` | One author block per author at the bottom of a post. |
| Author profile | author term `field_account` → user | When linked, name / description / avatar come from the user (`field_full_name`, `field_bio`, `field_avatar`). |
| Reading time | `field_reading_time` | Computed on save from the text in the components (220 wpm; transcripts excluded). |
| Has video | `field_has_video` | Set on save when a YouTube player or video hero is placed. Drives the Video filter on `/blog`. |
| AI disclosure | `field_ai_disclosure` (AI Disclosure module) | Default profile "AI first draft, human rewrite"; always shown on posts. |
| Personal views | `field_personal_views` (boolean, Page data) | Tick on posts with a personal opinion: the story ends with *My views are my own and not those of my employer.* (inside the story, so the Markdown version keeps it). |
| Summary | `field_summary` | At most three sentences (`MarcusMaxSentences` constraint). Opens in the "The post in three sentences" modal. |
| Spoken article | `field_spoken_audio` (file, `public://spoken`) + `field_spoken_duration` (seconds) | The article read out; plays inline. Feeds `/articles-read-out.xml`. |
| Podcast episode | `field_podcast_audio` (file, `public://podcast`) + `field_podcast_duration` + `field_podcast_transcript` | A conversation about the post ("Speaker: line" per turn). Feeds `/podcast.xml`. |
| Narrated slides | `field_slides_video` (MP4/WebM, `public://slides`) + `field_slides_duration` | A slide deck with voice-over ("View or present as slides"); self-hosted, plays in a modal. |
| Slide deck | `field_slides_deck` (JSON: title, slides, cues; hidden on the form) | The same slides as a deck visitors can present on the post (`?presentation=true`), narrated by the slides video's sound. |

Canvas pages have no Field UI: the fields are config in `web/modules/custom/marcus_blog/config/install`
and appear in the editor's **Page data** panel. Canvas pages must not have a stored *view*
display (Canvas throws), so never add one.

## The post frame

`marcus_blog` wraps a post's component tree (`hook_canvas_page_view_alter`) in the
`marcus_blog_post` theme hook; `marcus_theme/templates/content/marcus-blog-post.html.twig` renders it:

- `reading-progress` bar measured against the story;
- a fixed `table-of-contents` built **server-side** from the post's `chapter-marker` components
  (sticky sidebar on large screens, a "Chapters" panel on small ones);
- after the story: category pills, the AI disclosure, one `author-bio` per author.

**Chapters:** place a `Chapter marker` component where a chapter starts. It renders an anchor
(and optionally a visible heading). Its title is the table of contents entry.

## Components (all SDC, `web/themes/custom/marcus_theme/components`)

- From the magoo catalog (generated by `magoocomponentui`): article-header, byline, rich-text,
  pull-quote, figure-caption, code-block-ai, section-wrapper, split-view, spacing-box, card-grid,
  card-blog, hero-centered, stylized-header, cta-simple, newsletter-signup, newsletter-inline,
  video-player-youtube, video-hero, duration-badge, button, tag, back-to-top, reading-progress.
  Rendered by the theme only (list props Canvas cannot store): navbar, footer-columns,
  table-of-contents, author-bio, video-transcript, video-chapters, pill-nav, pagination,
  share-bar, tag-list.
- Theme-owned: `chapter-marker`, `video-transcript-text`, `video-chapters-text` (one
  `m:ss text` line per cue / chapter; they render the catalog transcript / chapters components and
  sync with a YouTube player through the same **Sync ID**).
- `video-player-youtube` props: `width` (contained | full), `autoplay` (always muted; skipped in
  reader mode / reduced motion), `muted`, `sync_id`, `poster` (URL, e.g. the YouTube thumbnail
  `https://i.ytimg.com/vi/<id>/maxresdefault.jpg`).

To change a catalog component, edit it in `magoocomponentui/components/...` and rebuild:

```bash
MAGOO_CACHE=$PWD/magoocomponentui node magoocomponentui/skills/drupal-theme/bin/magoo \
  build <category/name> --target sdc --out web/themes/custom/marcus_theme/components --canvas
# (--canvas last; leave it off for video-player-youtube and the theme-rendered ones above)
# the CLI writes into components/sdc/<name>: move it up one level, then:
ddev npm run build:css --prefix web/themes/custom/marcus_theme && ddev drush cr
```

## Interactive posts (data components)

Theme-owned components for posts with figures: `count-up` (a number counting up, optionally full width over a muted background video loop with a 9:16 one for phones), `code-waffle`
(1,000-square waffle chart with a highlightable legend), `scroll-bars` (horizontal bars with an
optional linear/log toggle), `score-rings` (0-100 rings, Lighthouse style) and `scroll-steps` (a
timeline whose rail fills with the scroll position), `scroll-story` (scrollytelling: a pinned
waffle while text cards scroll past and highlight its groups), `agent-panes` (a pinned terminal
whose agent panes open and type while scrolling) and `chart` (interactive Chart.js 4.5.1 charts:
tooltips, legend toggles; self-hosted and fetched only when a chart nears the viewport). Data is written as plain text lines
("Label | value | tone | note") so Canvas can edit it. Every one animates on scroll only for
visitors without reader mode or reduced motion, and carries a hidden table with the exact numbers
(screen readers, Markdown version). Example: `scripts/create-vibe-config-post.php`.

`panic-cards` (example: `scripts/create-juniors-post.php`): moments that taught a lesson as a row of 8-bit
cards, one per line "icon | when | panic | lesson" (icons: camera, database, phone, laptop, server; anything else
gets an alarm bell). As a card scrolls in, its pixel icon shakes and an alarm blinks, then the lesson appears.
All real text; reader mode, reduced motion and the editor show every card calm with its lesson.

`career-quiz` (example: `scripts/create-juniors-post.php`): a playable game-show quiz, "Who wants to be
a senior developer?": one question at a time, each for a prize shown above it (`prizes`, "$100 | ... |
Company stocks"), four answers and "Final answer?" to lock in; a wrong answer ends the round and the closing screen shows the right
answer ("Try again" retries that question, "Play again" starts over); finishing a round shows the
optional `outro` question with its `outro_answer` instead. The classic lifelines show as used, on the right; with `ai`
on, "Ask the AI" pulses like an arcade button whenever it can be used (a steady glow with reduced
motion), points at the right answer on every question and is counted (`{ai}` in the winning
message); "AI Auto Mode" under it lets the AI play the round by itself, a question every 4 seconds, until it is pressed again or the player clicks anything. `next_questions` adds a second round: the closing screen of a won first round offers it
(`next_prompt`, "Ready to run another game with a new lifeline?", and a "Let's play!" button) and
round 2 brings the "Ask the AI" lifeline, with its own `next_outro_answer`, `next_win` and
`next_lose`; "Play again" restarts the current round. An optional `intro` (one speech bubble per line) puts an 8-bit host (an inline SVG sprite) on a stage above the game: the bubbles come one by one as the page scrolls, typed while the host talks, with a pixel scroll-down icon in the corner of the latest bubble, then "Play the game" (scrolls to the game) or "Skip the game" (scrolls past it). While it plays, the game fills the window below the sticky header at a fixed height (buttons coming and going never move the page; the Skip button runs along the bottom). With `hold` on, the page stops at the end of the intro until a choice is made, and can't be scrolled past the game: scrolling down stops when its
bottom reaches the screen's, until the game ends (with two rounds, once round 2 ends) or the huge "Skip this game" button
under it is pressed; scrolling up, links to the page's own headings and tabbing past it stay free, and
reduced motion never holds. Questions "question | right | wrong | wrong | wrong". Keyboard playable,
results announced; reader mode leaves the game out, the editor lists the questions and right answers.

`focal-explorer` with `focal-photo` children in its Photos slot (example: `scripts/create-focalpoint-post.php`,
data from the focal point eval's `export_page.py` in `.media-work/focal-points/`): a full-width band for a
focal point eval. Each photo carries the marked point ("x,y" in percent) and one answer line per method, model
and prompt ("Method | Model | prompt | x,y x,y x,y"). The explorer filters by method, model and prompt (methods
have a fixed colour, the same on their checkbox; models a marker shape), switches the marked points, each run,
the middle of the runs (per-axis median), distance lines and crop frames on and off, and has a Crops view (one
method's crops next to the marked point's). A distance graph and an averages table follow the filters. The
filters stick on wide screens and fold behind a button on phones. Its data props are left out of reading time.

`crop-race` (Focal photo children in its Photos slot): contenders ("Label | Method | Model | prompt | $ per 1,000 |
seconds per photo") crop the same photos side by side at their measured speed while photos, model time, cost and
"kept my point" count up; a speed select (1× by default) and a crop select. `method-steps` with `method-step`
children: a full-width walkthrough, one tab per method, its steps side by side (an image of what the model saw and
what it answered). `prompt-tabs`: plain texts in tabs ("## Tab name" starts a tab), shown as written; prompt texts
and race data don't count toward reading time. Example for all three: `scripts/create-focalpoint-post.php`.

`play-cta` (example: `scripts/create-mac-packwala-post.php`): a big 8-bit call to action for something to play or
try: an optional screenshot on top, a terminal-style kicker, title, text, one huge full-width button, an optional
second button and facts as chips (one per line). Links to other sites open in a new tab. `pixel-gallery` with
`pixel-shot` children in its Screens slot: pixel-art screenshots in a grid (two columns from tablet width). Both
show the uploaded file itself, not Canvas's resized AVIF copies, with `image-rendering: pixelated`, so upload
pixel art at a whole multiple of its size (a 320 x 200 game canvas at 1280 x 800, scaled with nearest neighbour).

`prompt-slots` (example: `scripts/create-vibe-coding-stress-post.php`): an 8-bit slot machine for "roll another
prompt". One reel per line in `reels` ("Label | what a miss means"), one pull per line in `pulls` ("lines of code |
hits | tokens | what happened", hits one character per reel, 1 or 0), so every reader gets the same story. The
reader pulls the lever; the reels stop on a 7 (hit) or a bug, question mark or cross (miss), the code base blocks
and tokens spent count up, a jackpot flashes the marquee, and Auto-spin plays the rest after three pulls. Reader
mode and the Canvas editor show the pulls as a table (always in the markup); reduced motion stops the reels at once.

`image-figure`: an image in the reading column with an optional caption and credit (resized copies via srcset). Use
it instead of the catalog's `figure-caption`, whose required image (with an example that doesn't exist here) means
`canvas_add_component` can't place it. Example: the meme in `scripts/create-vibe-coding-stress-post.php`.

**Previewing drafts:** Preview Link (`preview_link`, with Dynamic Entity Reference) is enabled for Canvas pages:
the "Preview link" tab on a page, or the entity API (see the focal point post script), gives a secret URL
(`/preview-link/canvas_page/<id>/<token>`, valid 7 days) that shows the unpublished page with its post frame to
anyone. Those pages send `noindex, nofollow` (meta tag and `X-Robots-Tag`, SeoHooks). Scripts that recreate a page
change its ID, so earlier preview links stop working.

Release-post pieces (example: `scripts/create-ai-1-5-post.php`, the AI 1.5 post):
- `chat-story`: pinned scrollytelling; chatbots (admin, support widget, rich widgets) take turns,
  each answered by a different backend behind one layer ("style | chatbot | processor | via |
  question | reply | widget").
- `embedding-race`: one request per text against one batch request, run when it scrolls into view.
- `stream-redact`: a stream with `[SENSITIVE]` parts, raw on the left, redacted on the right.
- `refine-field`: a field with an AI button and a refine dialog ("request | result" turns).
- `rerank-list`: search results move from the engine's order to the reranked order while scrolling.
- `otel-flow`: pinned span → exporter → destination; scrolling swaps in the next destination
  ("name | note | logo URL"; logos self-hosted in `marcus_theme/images/logos`, see its README).
- `callout`: a boxed warning or info note with a content slot (text, code blocks).
The played ones (race, stream, refine) start when visible and have a replay button; all of them
show their finished state, without buttons, in reader mode, with reduced motion and in Canvas.

## Look, light/dark and reader mode

- Design tokens (colors, fonts, radii, spacing) are runtime settings:
  `/admin/appearance/settings/marcus_theme`. No rebuild needed.
- Dark mode: toggle in the navbar (base theme `color-scheme.js`).
- **Reader mode** (blog posts only): toggle in the navbar of posts; also on by default there when the
  OS asks for reduced motion. Other pages never get the toggle or the reader styling
  (`<html data-page-kind="post">` marks posts); a visitor's choice is kept for the next post.
  It sets `data-reader="on"` on `<html>`, which removes every animation, transition and every
  element marked `data-decorative` (the terminal backdrop), and loosens the reading column.
- Backdrop (front page and posts, `templates/partials/backdrop.html.twig`): a quiet terminal
  screen. A 22px pixel-dot grid with some dots swapped for code glyphs, faint CRT scanlines, a slow
  refresh bar and a prompt that runs `cat <page>.md` (the page's real Markdown version). No pointer
  tracking. Marked `data-decorative`.
- Effects layer (`js/effects.js`): AOS scroll reveals (lazy-loaded, never in
  reader mode or inside the Canvas editor), full-bleed breakout for "full" width videos next to
  the post sidebar.

Reader mode also hides the video components (YouTube player, video hero, chapters, transcript,
duration badge, and a split-view / section that frames a player), the post sidebar (one centered
column), the post's chapters and "Other ways to consume this" (on every screen size), the
newsletter sign-up components and anything marked `data-reader-hide`. A chapter heading that
only introduces a player, chapter list or transcript ("Watch the video", "Transcript") goes too,
with a short note between such a heading and a transcript. The
YouTube player does not even load its embed while reader mode is on, and pauses if it is switched
on mid-play.

**Contract for future three.js / heavy effect components:** check `window.marcusReader.isOn()`
on init and listen for the `marcus:reader` event on `document` (`event.detail.on`); render a
still frame (or nothing) while reader mode is on. Mark purely decorative wrappers `data-decorative`.

## Listings

View `blog_posts` (rows rendered as `card-blog` SDCs by `marcus_blog`): `/blog` (filters: search,
category, author, video) and a "Latest posts" block used on the front page. Listing pages show one full-width row per
post (`card-blog` with `layout: horizontal`: preview image beside the text); the front page block keeps a
three-column grid. Pager renders through the `pagination` SDC.

`/blog` has a filter bar: **Search** (`?search=`; all words, over title, description and
`field_search_text`, the plain text of every component including video transcripts, stored on save) and
**Category** (`?category=<term id>`), **Author** (`?author=<term id>`) and **Video** (`?video=1`: only posts with a YouTube player or video
hero; there is no separate video listing, the "Videos" menu link and front page button point here). **Podcast** (`?podcast=1`: only posts with a podcast episode; a grouped exposed filter on
`field_podcast_audio`, no extra field).
Main menu (`marcus_blog.links.menu.yml`): Blog, Videos (`?video=1`), Evals and About. **Evals** uses
`CategoryMenuLink`, which links to `/blog?category=<id>` by looking up the category by name
(`metadata: category`), because term IDs differ between sites. Add another category the same way.
(The search parameter is not `q`: Views drops `q` from exposed input.)

**Preview images**: every page has one. Cards, Open Graph and JSON-LD use the page's own image (the
Canvas page "Image" field), else its first video poster, else `marcus_theme/images/default-preview.png`
(1200 x 630, rendered from the pixel-art scene with `marcus_at_laptop.py --png`). Agents set it with
`marcus_blog_set_post_fields` (`image_url` + `image_alt`: a public http(s) image or a file under
`/sites/default/files/`; private and loopback hosts, redirects and non-images are refused).

## Privacy: cookie consent and no third parties before consent

- **Klaro!** (`klaro`, library `web/libraries/klaro` from `drupal/klaro_js`, served locally) shows
  the consent notice and the "Cookie settings" manager (footer link `#klaro`). Two optional services:
  **YouTube videos** (`youtube`) and **Google Analytics** (`google_analytics`), plus Klaro's required
  "Functional" and "Consent manager" entries. Configured by `scripts/setup-klaro.php` (texts,
  services, permissions); styled with the theme tokens in `css/src/styles.css`. It replaced COOKiES
  (2026-10-09) for performance: no jQuery, no extra config request, about half the script.
- The YouTube player loads **nothing** from YouTube until `video` consent exists: it shows a
  "Load video" overlay instead. The contract is generic (`window.magooConsent.has/request`,
  `magoo:consent` event, in `video-player-youtube/behavior.js`); `marcus_theme/js/consent.js` bridges
  Klaro to it (component service `video` = Klaro `youtube`, `analytics` = `google_analytics`).
- Posters are local files (`public://video-posters/<id>.jpg`): a YouTube thumbnail URL would contact
  Google before consent. Give new players a local poster.
- Fonts (Plus Jakarta Sans, JetBrains Mono, Tiny5) and AOS are self-hosted in the theme: a page view
  makes zero third-party requests until the visitor allows video.

## Share buttons

Better Social Sharing Buttons (`/admin/config/services/better_social_sharing_buttons/config`):
LinkedIn, X, Bluesky, Reddit, email, copy link; plain links, no tracking script, so no consent
needed. Rendered in every post's footer ("Share this post"). No security-covered module with a
share or link-card preview was found; Metatag's search result preview is in the Canvas "Page data".
The icons use the module's "no color" sprite and take the text color (theme CSS), so they are
dark in light mode and white in dark mode. Clicks are tracked as GA `share` events (after consent).

## Look: clean 8-bit

- Fonts: **Tiny5** on the logo (`--marcus-font-logo`), **Geist Pixel** for all headings (the
  `font_heading` token) and the hero title / buttons (`--marcus-font-display`), Plus Jakarta Sans for
  body text, JetBrains Mono for code. Tight corners and hard offset shadows are token settings (`radius_*`, `shadow_*`).
  (Tried and dropped: Pixelify Sans, whose "5" reads as "S"; Jersey 10. Tiny5 was too chunky for headings but suits the logo.)
- **Reader mode** switches every font to Plus Jakarta Sans (logo, hero, headings, the monospace
  subtitle); only code blocks keep their monospace font.
- **Pixel hero** (`pixel-hero` SDC, placeable in Canvas): title, subtitle, intro, two buttons and the
  pixel-art scene of Marcus at his laptop. The scene is generated by
  `scripts/pixel-art/marcus_at_laptop.py` (shapes rasterised at `SCALE` x the 64 x 40 design grid; 3
  by default, 1 for chunky 8-bit; needs Pillow) into `templates/partials/pixel-scene.svg.twig`; its desk,
  glow and sparkles use the theme tokens.
- Favicon: the workflows-of-ai.com brain in black (`favicon.ico`, PNGs, apple-touch icon) plus an SVG
  that turns white on dark browser tabs.
- Site name **Workflows of AI**, slogan **Marcus adventures in AI and code**, tagline (front page intro,
  footer, default meta description, llms.txt): *Articles, videos and interactive pages about the highs and
  lows of AI. Maybe with a splash of humour, code and Drupal.* The navbar logo shows the
  brain icon before the name (`.navbar__logo::before`, the favicon artwork as a CSS mask over the text
  colour, so it follows dark mode).

## SEO, GEO and agents

| What | How |
|---|---|
| Meta tags | Metatag defaults: global, front page, `canvas_page`, author terms, and per listing display (metatag_views). Open Graph + Twitter cards. |
| JSON-LD | Schema.org Metatag prints it from the token defaults; `marcus_blog` `StructuredData` (run last, from `SeoHooks::pageAttachmentsAlter`) turns everything into **one connected `@graph`**: `WebSite` + `WebPage` (url, name, `isPartOf`, breadcrumb) on every page; posts add `BlogPosting` (`mainEntityOfPage` the WebPage, image size, the AI disclosure's `digitalSourceType`) and `VideoObject`; listings `CollectionPage` + `ItemList` (category lists filled from the posts); about pages are a `ProfilePage`. The writer is **one `Person`** (`@id` `/about/<name>#person`, `sameAs` = the author term's profile links, project pages left out) that every `author` / `publisher` points at. Booleans are real booleans, dates ISO 8601 with `+02:00`. |
| Post-specific tokens | `marcus_blog` adds `[canvas_page:marcus-*]` tokens (video id/name/urls/thumbnail/transcript, author name/url, share image, categories, reading time) and trims article/video tags from pages that are not posts / have no video (`hook_metatags_alter`). |
| Markdown for agents | Markdownify: append `.md` to any Canvas page URL, or send `Accept: text/markdown`. YAML front matter (markdownify_metadata), corrected by `marcus_blog`: `author.url` is the writer's about page, plus `date` (published) and `updated` in local time. `marcus_blog` cleans the HTML first: videos become a YouTube link, chapters and transcript become lists, captions become paragraphs, chrome is dropped. Markdownify advertises it (`<link rel="alternate" type="text/markdown">` + `Link` header). |
| llms.txt | `/llms.txt` (llms_txt_generator), rebuilt by `marcus_blog` whenever a Canvas page or category changes: every post with its `.md` link, topics, pages. |
| ai.txt | `/ai.txt`: text and code allowed for AI use, images / audio / video not. |
| Sitemap | `/sitemap.xml` (Simple XML Sitemap): Canvas pages (minus the `/home` duplicate of `/`), writers' about pages and `/blog`. `SitemapHooks` gives `/blog` a `lastmod` (newest change of a post). Categories are filters, not pages, so they are not in the sitemap. |
| robots.txt | **RobotsTxt** module (`/robots.txt`, rules editable at `/admin/config/search/robotstxt`, exported as `robotstxt.settings`); the scaffolded static file is off (`composer.json` `drupal-scaffold.file-mapping`). Core's rules plus an explicit **allow** group for AI crawlers (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User, Google-Extended, Applebot-Extended, CCBot) with the same rules. `marcus_blog` adds the `Sitemap:` line with the current domain (`hook_robotstxt`). |
| Front page | `/` is canonical (and `og:url`, `og:title` = site name, share image with size + alt). **Redirect** module: `/home` → `/` (a redirect entity, created by the deploy hook `marcus_blog_deploy_front_page_redirects`); `/page/<id>` goes there through Redirect's route normalizer. Never add a redirect on the front page's system path: `/` is processed into it and would loop. Redirect also adds a 301 from the old URL whenever a page's alias changes. |
| Share images | Canvas pages: their preview image with `og:image:width/height/alt`; every other page falls back to `images/default-preview.png` (1200x630). |
| Reading time | Computed on save (`field_reading_time`); the byline component's `read_time` prop is rewritten to match on every save, so page, Markdown, RSS and llms.txt agree. |

Configuration lives in `scripts/setup-seo.php` (re-runnable).

## Context Control Center (AI context)

`ai_context` (CCC 1.0.0-rc1, with AI + AI Agents) holds reusable guidance for AI features. The item
**How Marcus writes** (`scripts/setup-writing-context.php`) captures the writing voice, title style
and post structure, derived from Marcus's own public writing. Its required child **How Marcus talks** (`scripts/setup-voice-context.php`) is derived from the
transcripts of his workflows-of-ai.com videos in `docs/voice/transcripts/` (all 63).
Attach them to agents at
`/admin/config/ai/context/settings/agents`. An AI provider still has to be configured
(`/admin/config/ai/providers`, API key in a Key entity per AGENTS.md).

## Writers and about pages

- Every writer is an Authors term linked to a site account; the account holds the profile: full name,
  short description, avatar and **About** (`field_about`, formatted text in **Basic HTML** with CKEditor 5: paragraphs, links, lists, quotes, headings).
- `/about/<name>` (Pathauto pattern `author_about`) is the writer's about page: profile header, the
  About text, their **6 latest posts**, and "View all posts" to `/blog?author=<id>`.
- `/about` lists every writer (main menu "About"). The author block under each post links to the
  writer's page. Core's node-only `taxonomy_term` and `frontpage` views are disabled.

## Post versions: "Other ways to consume this"

Under the chapters in the post sidebar (on small screens both come after the article, before the
post footer) a post lists its other versions, each shown only when its field is filled:

- **Spoken article**: a slim inline `audio-player` ("Listen to this article") that plays right
  away, with a tiny link "Articles read out (podcast feed)" to `/articles-read-out.xml`.
- **Podcast**: "Listen to our AI generated podcast · m:ss" opens a `modal` with the player
  (autoplays), "Listen in your podcast app" and the transcript as speaker turns.
- **Listen in your podcast app** (in the podcast modal): plain links, nothing third-party loads.
  Overcast (iPhone, `overcast://x-callback-url/add?url=<feed>`) and AntennaPod
  (`antennapod.org/deeplink/subscribe?url=<feed>`) are built from the feed URL; "Copy RSS feed"
  copies it. Apple Podcasts, Spotify, Pocket Casts, Castro and "All apps…" (a pod.link page) show
  once their link is set, which needs the production feed submitted to them first:
  `ddev drush cset marcus_blog.settings podcast_links.apple_podcasts 'https://podcasts.apple.com/...'`
  (keys `apple_podcasts`, `spotify`, `pocket_casts`, `castro`, `pod_link`), then
  `ddev drush config:export -y` and commit. pod.link builds its page from the feed:
  `https://pod.link/id?href=<feed URL>` (claim it with the feed's owner e-mail).
- **Narrated slides**: "View or present as slides · m:ss" opens a modal with the self-hosted
  `video-player` (autoplays). No third party involved, so no consent needed.
- **Present the slides**: with a deck (`field_slides_deck`), the slides modal has "Present the
  slides yourself", and `/blog/<slug>?presentation=true` opens it from a link. It is reveal.js
  5.2.1 (self-hosted in `js/vendor/revealjs`, fetched only when someone presents) built over the
  post by `js/presentation.js` with `css/presentation.css` (site tokens, so light/dark follow the
  site). The first click or key press goes fullscreen (browsers allow it only after one).
  Narration starts off; N or the "Narration" button turns it on: it is the slides video's sound,
  `cues` say where each slide starts, and the deck then moves on by itself. F fullscreen, S
  speaker view, Close returns to the post. `?presentation=true&video=1` is the silent version the slides skill films.
- **Summary**: "Summary in 3 sentences" opens the "The post in three sentences" modal.

Only one audio or video plays at a time; closing a modal pauses it (`js/post-versions.js`). Reader
mode hides the whole block, like the chapters. In the Markdown version the players become links
to the media files. The data comes from `BlogPresenter::versions()`;
templates: `partials/post-versions.html.twig` and `partials/post-version-modals.html.twig`.

**Podcast feeds** (Podcast module, Views with the `podcast_rss` style; config in
`marcus_blog/config/optional`):

| Feed | URL | Episodes |
|---|---|---|
| Workflows of AI Podcast | `/podcast.xml` | Published posts with `field_podcast_audio` |
| Articles read out | `/articles-read-out.xml` | Published posts with `field_spoken_audio` |

Both carry enclosure, duration, author, categories and the cover `images/podcast-cover.png`
(1400×1400, from the pixel-art script). The podcast feed links each episode's transcript at
`/podcast/transcript/<page id>` (text/plain; a `.txt` path would be served statically by nginx).
Both feeds are listed in the footer and in llms.txt. Edit channel details (owner e-mail,
iTunes category, description) in the views at `/admin/structure/views`.

**Placeholders** until the real versions are made (e.g. ElevenLabs):
`python3 scripts/placeholder-audio.py podcast TRANSCRIPT.txt OUT.mp3` (one robot voice per
speaker) or `... spoken ARTICLE.txt OUT.mp3`, and
`python3 scripts/placeholder-slides.py SLIDES.txt OUT.mp4` (slides in the site's 8-bit look with
a flite voice-over; source format `# title` / `- bullet` / `> narration`, blocks separated by blank
lines). Both print the duration in seconds and need ffmpeg with libflite (and libx264 for slides).
Set the files on a post with `marcus_blog_set_post_fields` (below); a file under
`/sites/default/files/` works as the URL.

## RSS

`/rss.xml` (`marcus_blog` FeedController): RSS 2.0 of the 20 latest posts with author, categories,
summary + reading time and the preview image. Advertised in every page's `<head>`, in llms.txt, in the
and in the footer.

One category's feed: `/rss.xml?category=<term ID>` (the same ID as the `/blog?category=<term ID>`
filter; an unknown ID is a 404). The channel is titled "Workflows of AI: <category>" and links to the
filtered listing. `/blog` filtered by a category advertises its feed in `<head>` and shows an
"RSS feed for <category>" link above the posts (`.marcus-category-feed`).

## Google Analytics and production settings

**Events** (`marcus_theme/js/analytics.js`, sent with `gtag('event', ...)` only after the visitor
allowed Google Analytics in the banner): `share` (method = x, linkedin, bluesky, reddit, email,
copy), `podcast_open`, `podcast_play`, `slides_open`, `summary_open`, `spoken_article_play` (first
start per page view), `podcast_app` (app = overcast, antennapod, apple_podcasts, spotify,
pocket_casts, castro, pod_link, copy_feed), `feed_subscribe` (feed = rss, podcast, articles_read_out; any link to a
feed). The Google Analytics module
Mark the custom ones as key events in GA if wanted. (The old Google Analytics module's automatic
outbound-link and download events are gone with the module.)

- Google Analytics without a module: `marcus_blog` (`AnalyticsHooks`) hands the measurement ID from
  `marcus_blog.settings:ga_id` to public pages (never admin pages or the Canvas editor), and
  `marcus_theme/js/analytics.js` loads `gtag.js` only after the visitor allows "Google Analytics" in
  Klaro (withdrawing consent switches it off). Dev uses the bogus ID `G-XXXXXXXXXX`.
- `settings.php` includes `settings.production.php` only when `DRUPAL_ENVIRONMENT=production`. Copy
  `web/sites/default/settings.production.php.example` (in the repo; the real file is gitignored). It
  reads `GOOGLE_ANALYTICS_ID` (into `marcus_blog.settings:ga_id`), `DRUPAL_HASH_SALT`, `DRUPAL_TRUSTED_HOST` and `DB_*` from the
  environment, hides errors and turns on aggregation.

## Publishing over MCP

Endpoint: `https://<site>/mcp` (OAuth 2.1 with dynamic client registration, via the Agent
Access recipe). Ask for the scopes **`drupal:mcp:connect`** and **`drupal:canvas:publish`**; the
signing-in account needs the **MCP publisher** role (the `marcus` account has it).

Tools exposed (`tool_api__*`): all Canvas Tools (create page, add / move / delete components,
update props, publish auto-saves, set homepage, templates...), `marcus_blog_set_post_fields`
(kind, categories, authors by name and creates missing terms; `image_url` / `image_alt`;
`summary`; `spoken_audio_url` + `spoken_duration`; `podcast_audio_url` + `podcast_duration` +
`podcast_transcript`; `slides_video_url` + `slides_duration`; `slides_deck` (the deck JSON). URLs must be public http(s): private hosts are refused and redirects are not
followed), `ai_disclosure_record`,
`entity_list`, `entity_metadata`.

A typical agent flow: `canvas_create_page` → `marcus_blog_set_post_fields` (kind=post,
categories, authors) → `canvas_add_component` + `canvas_update_component_props` per block
(`chapter-marker` at each chapter) → `canvas_update_page_metadata` (published) →
`canvas_publish_auto_saves`.

Local test without OAuth: `ddev exec 'drush mcp:server marcus'` speaks MCP over STDIO.

## Deployment and content push

See `scripts/deploy/README.md`. In short:

- **Code + config**: export config (`ddev drush config:export`, into `config/sync`), commit,
  then `git tag vX.Y.Z && git push origin vX.Y.Z`. GitHub Actions (`.github/workflows/deploy.yml`)
  builds core/contrib/vendor/theme CSS, and `scripts/deploy/deploy.sh` on the Droplet backs up
  database, files and code, switches the release, runs `drush deploy` and rolls back on failure.
- **Content**: `scripts/deploy/push-content.sh /blog/my-post` exports the page with its media,
  files and terms (`drush marcus:content-export`, never user accounts), backs up the production
  database and imports it there (`drush marcus:content-import`: creates new, updates existing by
  UUID). Media never goes into git.
- The repository holds code and configuration only (`.gitignore`); `magoocomponentui/` is its own
  repository.

## Accounts and roles

- `admin` (uid 1): site admin, also has MCP publisher.
- `marcus` (uid 2): **Publisher** (write, edit and publish Canvas pages, manage categories /
  authors, media) + **MCP publisher**. Log in with `ddev drush uli --name=marcus`.

## Scripts

- `scripts/setup-ai-disclosure.php`: disclosure profile + field on Canvas pages.
- `scripts/seed-content.php`: site name, categories, the AI 1.5 post and the front page, built
  through the MCP tools.
- `scripts/setup-marcus-user.php`: the `marcus` account and profile, linked to the author.
- `scripts/setup-seo.php`: metatag / JSON-LD defaults, Markdownify, sitemap, ai.txt, llms.txt.
- `scripts/setup-writing-context.php`: the "How Marcus writes" context item.
- `scripts/setup-marcus-about.php`: the About text on the marcus account.
- `scripts/placeholder-audio.py`: robot-voiced placeholder podcast / spoken-article MP3s (see
  "Post versions"); sample texts in `docs/sample-content/`.
- `scripts/placeholder-slides.py`: placeholder narrated slides MP4.
- `scripts/setup-klaro.php`: the Klaro! consent manager (services, texts, permissions).
- `scripts/import-workflow-videos.php`: the old workflows-of-ai.com videos as posts (see below).
- `scripts/front-page-sections.php`: adds "Drupal AI in your browser" (a card linking to the Drupal
  AI demo on Drupal Forge; image self-hosted in `public://promo/drupal-ai-demo.jpg`) and "Latest
  podcast episodes" (Views block `blog_posts:block_podcast`: up to three episode players, then the
  "Listen in your podcast app" row) to the front page: the podcast after "Featured video", the
  demo card at the bottom. Re-runnable.

## The old workflows-of-ai.com videos

All 63 videos from the old site are posts at `/blog/<old slug>` in the category **Workflows**
(plus Drupal AI / Agents / Release notes). Each has the header, "About this video" (Marcus's
original text from the old page, spelling fixed), "Watch the video" (YouTube player with a local
poster and video chapters) and "Transcript" (YouTube's automatic captions, merged into readable
cues, misheard names corrected). `created` is the original publish date, so they sort by when the
video came out. AI disclosure: "Partly AI assisted"; the note says what AI wrote (subtitle,
summary, chapters, caption fixes).

- Data: `docs/voice/workflow-posts/<slug>.json` (old title, date, intro; generated subtitle,
  description, summary, categories, chapters, caption corrections) and
  `docs/voice/transcripts/<slug>.md`. Edit those and re-run
  `ddev drush php:script scripts/import-workflow-videos.php [slug ...]` (re-creates the page).
- Posters: `public://video-posters/<video id>.jpg`, 1280×720 from the old cover images (non-16:9
  covers fitted over a blurred fill). Content, not in git: publish with the content push.
- Old URLs: `/workflow/<slug>` answers 301 to `/blog/<slug>` for all 63 videos (Redirect entities
  made by the deploy hook `marcus_blog_deploy_workflow_redirects` from `docs/voice/videos.tsv`, so
  production gets them with `drush deploy`). They work as soon as workflows-of-ai.com points at this
  site; if the site runs on another domain, the old domain needs a redirect that keeps the path.
- Long transcripts need `marcus_blog`'s `JitSafeComponentValidator`: see "Known limitations".

## Images and quality checks

- **Responsive images in Canvas components:** the magoo SDC generator gives every `<img>` fed by a
  Canvas image prop a `srcset` from Canvas's `toSrcSet` Twig filter (AVIF derivatives of the
  `canvas_parametrized_width` style), plus intrinsic `width`/`height`, `sizes="auto, 100vw"` and
  `loading="lazy"`; a template can override `sizes`/`loading` (the byline avatar says `sizes="40px"
  loading="eager"`). Rebuild a component to pick it up.
- Listing cards use the `marcus_card` image style (800 wide, WebP) and author photos the
  `marcus_avatar` style (384x384, WebP) via `BlogPresenter`; Open Graph / JSON-LD keep the original
  file.
- Lighthouse (2026-10-09, mobile, median of 3 on DDEV, after Klaro + responsive images):
  performance 97-99 (LCP 1.8-2.4 s, TBT 10-37 ms, ~40 KiB of JS), accessibility 97-100, best
  practices and SEO 100, agentic browsing 98-100. Re-run with
  `CHROME_PATH=/usr/bin/google-chrome npx lighthouse@13 <url> --chrome-flags="--headless=new --ignore-certificate-errors"`.
- Known Lighthouse flags: the byline meta "contrast" is a false positive (it measures against the
  blurred backdrop orb; real contrast is 5.6:1); mobile LCP is the cookie banner text on a first
  visit (still under 2.5 s); DDEV's nginx sends fonts/CSS
  with `no-cache` (Drupal's `.htaccess` sets a one-year expiry on Apache).

## Known limitations

- **canvas_tools cannot place block components** (e.g. the "Latest posts" Views block): it adds
  them with the block plugin's bare default configuration, which lacks the `label` /
  `label_display` inputs Canvas requires. The seed script appends that block directly.
- Agent Access, Canvas Tools, MCP Server, Tool API and AI Disclosure are alpha/beta and **not
  covered by Drupal's security advisory policy**.
- **Contact form**: `/contact` is the Webform `contact` (name, email, message), linked as "Contact me"
  in the footer's About me column. Messages are only stored on the site (no email handlers, no IP
  address saved): read them at `/admin/structure/webform/manage/contact/results/submissions`. Spam:
  Honeypot (hidden trap field plus a 5 second minimum, longer after failed tries from the same IP),
  switched on in the webform's third-party settings. Email notifications need an SMTP service first.
- No newsletter: the presentational forms were removed (no mailing list was wired up); readers
  follow the RSS and podcast feeds. If email is wanted: Simplenews + Mailer Plus (both
  security-covered) and an SMTP sending service (DigitalOcean blocks port 25).
- `markdownify_views` (Markdown for Views pages) crashes on Drupal 11.4 and is not installed;
  llms.txt lists the listing pages instead.
- markdownify_metadata's Schema.org front matter is switched off (incompatible with schema_metatag
  3.x); it still logs harmless "Array to string conversion" warnings when building front matter.
- SDC templates built by magoo do not apply prop `default`s, and Canvas only stores the inputs a
  component had when it was placed. A prop added to a component later renders empty on existing
  pages until it is set (as happened with the YouTube consent text): set it with
  `canvas_update_component_props`.
- **Long multi-line props (transcripts) failed to save:** Canvas marks textarea props with the
  pattern `(.|\r?\n)*`, and the SDC prop validation runs it through PCRE, which runs out of JIT
  stack on values over a few KB. `marcus_blog`'s `JitSafeComponentValidator` (swapped in by
  `MarcusBlogServiceProvider`) skips that matches-everything pattern, like Canvas's own
  `JitSafeRegexValidator` does for Symfony constraints. Remove it when Canvas fixes this.
- The AI 1.5 post (`scripts/create-ai-1-5-post.php`) uses a placeholder YouTube video (Big Buck
  Bunny) until the real walkthrough is recorded; its podcast, spoken article and slides are
  placeholders about the earlier version of the post.
- Its podcast (hosts "Ada" and "Ben"), spoken article and narrated slides are flite placeholders. The podcast
  feeds' owner e-mail is the placeholder `podcast@example.com`, and the cover image has no title
  text on it (podcast apps show the feed title next to it).
