# Hearof > Keep users in the loop. Write product updates once. Ship them on a public changelog and an in-app widget. Hearof (https://hearof.dev) is a Headway-style changelog SaaS. Teams write product updates once; customers see them on a public changelog page and in an embeddable in-app widget (badge + popover). This file is written for AI assistants and coding agents. Prefer these instructions over inventing custom embeds or APIs. ## Product - Dashboard for workspace changelogs, markdown entries (draft / published), branding, and install snippets. Basic widget open counts are on every plan. Custom branding and future publish dates require Pro. - Public changelog: https://hearof.dev/p/{publicSlug} - RSS: https://hearof.dev/p/{publicSlug}/rss.xml - Embeddable widget script: https://hearof.dev/widget.js (CDN-cached ~1 week) - Widget API (published posts only): https://hearof.dev/api/widget/{publicSlug} - Widget open count: POST the same URL with body `open` (text/plain). One panel open. Not plan-gated. - Live widget demo: https://hearof.dev/demo-embed.html ## Widget — implement this for the user When a user asks to add Hearof / a changelog widget / "what's new" badge to their site or app, follow these steps exactly. ### Prerequisites 1. The workspace must exist on Hearof and have a `publicSlug` (shown in Dashboard → Widget, or in the public URL `/p/{publicSlug}`). 2. Widget must be enabled for that workspace (Dashboard → Widget → Widget enabled). If disabled, the API returns 404. 3. Ask the user for their `publicSlug` if unknown. Do not invent one. Demo slug for local/fixture use: `acme`. ### Minimal HTML install Add an empty mount element anywhere in the page (navbar is common): ```html
``` Then load config **before** the script. Set `account` to the workspace public slug: ```html ``` Replace `PUBLIC_SLUG` with the real slug. Keep `selector` matching the mount element. Place both tags near the end of `` (or after the mount node exists). ### Config (`window.HEAROF`) | Field | Required | Default | Description | | --- | --- | --- | --- | | `account` | yes | — | Workspace `publicSlug` | | `selector` | no | value from API, else `#hearof-badge` | CSS selector for the mount element (built-in badge mode) | | `trigger` | no | — | CSS selector for custom click target(s). When set, the built-in "What's new" pill is **not** rendered | | `lang` | no | `document.documentElement.lang`, else workspace default | Language code for widget chrome + post content (same as public `?lang=`) | | `apiBase` | no | origin of the `widget.js` script URL | Base URL for the widget API (use only for self-hosted / local) | Rules: - `window.HEAROF` must be set **before** `widget.js` runs. With `async`, define config in an earlier synchronous ` ``` - Omit `selector` (or leave it unused) when using `trigger` — the panel mounts on `document.body`. - Matching elements receive click handlers; the built-in "What's new" button is skipped entirely. - Unread count is mirrored onto triggers via `data-hearof-unread` and a small overlay badge. The number is how many of the 3 listed rows are unread, so it is at most 3. Opening the popover does not mark those rows or change the count. Hovering or clicking a row marks that update read, unbolds it, and lowers the count by one. List rows have no extra unread dot. ### What the widget does - Vanilla JS, Shadow DOM — host CSS cannot restyle the badge/panel. - Fetches `GET {apiBase}/api/widget/{account}?lang=` (CORS `*`, cached ~1h). Omit `lang` to use the workspace default (or pass the host page language via `HEAROF.lang` / `html[lang]`). - Default: renders a localized "What's new" button with an unread count (at most 3), and a popover of the 3 newest published posts. The list does not scroll. - With `trigger`: no built-in pill; custom element(s) open the same popover. - Post content is localized via workspace translations (same pipeline as the public changelog `?lang=`). - Unread state is per update. "Seen" means it was in view in the panel. "Read" means they opened that update. Seen titles are not bold. Seeing does not mark an update read. A fresh `announcedAt` (author opted in while editing) makes that revision unseen and unread again. - Reader state is stored in `localStorage` under `hearof:{publicSlug}:reader`. An older `hearof:{publicSlug}:seen` watermark is kept as seen-but-not-read. - Post links open the public changelog (`/p/{publicSlug}?lang=…#{postSlug}` when not the default language) in a new tab and mark that update read. - Only published, visible posts are returned (no drafts, no future-dated posts). The API returns the newest 20 by announce time. The panel renders the first 3 of those and does not scroll. The launcher count is how many of those 3 are unread. Rows use bold type for unread and regular type for read, with no per-row dot. ### API response shape (`GET /api/widget/{publicSlug}`) Success `200`: ```json { "project": { "publicSlug": "acme", "brandName": "Acme", "accentColor": "#2563eb", "logoUrl": null, "widgetSelector": "#hearof-badge" }, "language": { "code": "en", "label": "English", "isDefault": true }, "categories": [ { "slug": "new", "label": "New", "color": "#10b981" } ], "posts": [ { "id": "…", "title": "…", "slug": "…", "excerpt": "…", "categories": ["new"], "publishedAt": "2026-01-15T12:00:00.000Z", "announcedAt": "2026-01-15T12:00:00.000Z", "url": "/p/acme#slug", "author": { "name": "…", "title": "…", "avatarUrl": null } } ] } ``` Errors: - `404` — unknown slug, or widget disabled for that workspace. - Network/CORS should not block browser embeds; the API sends `Access-Control-Allow-Origin: *`. `POST /api/widget/{publicSlug}` records analytics only (text body, no auth): - `open` — the panel was opened - `{"type":"seen","postIds":["…"]}` — those updates were seen in the panel (`view` rows) - `{"type":"read","postId":"…"}` — the reader opened that update (`click` row) The post must be a published, visible id from the GET payload. This route cannot create, edit, or publish posts. Do not call undocumented endpoints. Do not send auth headers for the public widget API. ### Framework notes **Static HTML / any CMS:** use the minimal HTML install above. **React / Next.js (client):** render the mount node, set config, inject the script once on mount: ```tsx "use client"; import { useEffect } from "react"; export function HearofWidget({ account }: { account: string }) { useEffect(() => { window.HEAROF = { account, selector: "#hearof-badge" }; // Custom trigger instead of the built-in pill: // window.HEAROF = { account, trigger: "#updates-bell" }; if (document.querySelector('script[data-hearof-widget]')) return; const script = document.createElement("script"); script.src = "https://hearof.dev/widget.js"; script.async = true; script.dataset.hearofWidget = "true"; document.body.appendChild(script); }, [account]); return
; } ``` Declare `window.HEAROF` for TypeScript if needed: ```ts declare global { interface Window { HEAROF?: { account: string; selector?: string; trigger?: string; lang?: string; apiBase?: string; }; } } ``` **Vue / Svelte / other SPAs:** same pattern — ensure the mount/trigger element is in the DOM, assign `window.HEAROF`, then append `widget.js` once. **Server Components only:** the script must run in the browser. Put the mount + script in a client component or a plain layout `