Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Mermaid diagrams export as PNG, with Copy/Download in the zoom lightbox** (`docs/src/integrations/diagram-png.mjs` (new), `docs/src/components/MermaidZoom.astro`, `docs/astro.config.mjs`, `docs/src/content/docs/development.md`): diagrams render as inline SVG (great for reading — selectable text, screen-reader semantics, theme-reactive colors) but can't be right-click-copied or dropped into a slide deck. A new `astro:build:done` integration now also rasterizes every diagram to `dist/diagrams/<slug>/<index>-<theme>[-transparent].png` — light **and** dark, 2× DPI, in two variants per theme (a WYSIWYG surface card and a transparent-background version for slide decks) — reusing the Playwright Chromium `rehype-mermaid` already needs, with a content-hash cache (`node_modules/.cache/wh-diagram-png`) so unchanged diagrams skip Chromium and the whole pass is wrapped so it can never fail the build. The zoom lightbox (open by clicking any diagram) gains **Copy** (async-clipboard image, falling back to download where unsupported) and **Download** buttons that act on the PNG for the current theme, plus a **background toggle** (checkerboard icon) that flips Copy/Download between the solid and transparent variant and previews transparency as a checkerboard on the zoom stage; inline diagrams stay button-free. The upstream `@wave-rf/astro-themed-mermaid` plugin is intentionally untouched — it's color-agnostic (CSS-var placeholders resolved at runtime), so a WYSIWYG light/dark PNG must be rasterized post-build where `global.css` + a theme apply. PNGs are gitignored build artifacts, regenerated in CI/deploy.

- **Site header override — a top-level "Docs" nav link, the search box kept expanded, and a splash-width header** (`docs/src/components/Header.astro` (new), `docs/src/config/sidebar.ts`, `docs/src/env.d.ts`, `docs/astro.config.mjs`): Starlight has no config hook for header links, so we override its `Header` component through the sanctioned `components` map. The custom header re-renders every built-in control (SiteTitle, Search, ThemeSelect, SocialIcons, LanguageSelect) through its `virtual:starlight/components/*` import — so their overrides and Pagefind search keep working untouched — and adds a `<nav>` with a single top-level **Docs** link (→ `/getting-started`), the way into the doc tree from the sidebar-less splash homepage. The link target lives in a new `headerNav` in `sidebar.ts`, colocated with the sidebar as one navigation source of truth, and the nav is hidden below the `md` (50rem) breakpoint where the sidebar drawer already carries navigation. Search keeps Starlight's expanded "Search … ⌘K" box on every page at ≥50rem (including the splash) so the keyboard shortcut stays discoverable, and its primary-nav link sits on the WaveHouse wordmark's baseline. On the splash homepage the header's inner content is held in a centered content-width column matching the hero, while doc pages stay full-bleed. `env.d.ts` gains type declarations for the built-in component modules the override re-renders so `astro check` passes.

### Changed

- **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient and claims-independent: column visibility derives only from the role+table policy entry, and the stream path applies no row-level filter (a documented invariant — if row-level filtering is ever added to streaming, the key must take claims into account). The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayFrame`; live frames carry the same `id: <received_timestamp>`). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total`; an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152).
Expand Down
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export default defineConfig({
},
},
components: {
Header: "./src/components/Header.astro",
Hero: "./src/components/Hero.astro",
SiteTitle: "./src/components/SiteTitle.astro",
Footer: "./src/components/Footer.astro",
Expand Down
204 changes: 204 additions & 0 deletions docs/src/components/Header.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
---
// Custom header overriding Starlight's default. The layout is our own: brand +
// primary nav pinned left, controls (search, social, theme, language) pinned
// right. On doc pages the bar is full-bleed to the edges; on the splash homepage
// (the only page with a hero) the inner content is held in a centered max-width
// column matching the hero, à la ClickHouse/Supabase. Search shows Starlight's
// expanded "Search … ⌘K" box on every page (≥50rem), so the keyboard shortcut
// stays discoverable. Every control is still Starlight's own built-in, pulled in
// through its `virtual:starlight/components/*` import, so the SiteTitle/Search/
// Theme overrides and the Pagefind-search logic keep working untouched. The
// outer <header class="header"> (PageFrame — fixed position + full-width glass
// background) is not ours and stays as-is; only our inner content is
// column-constrained on the splash, so the bar itself still spans the page.
//
// Because the controls are Starlight built-ins re-rendered here, env.d.ts
// declares their virtual modules so `astro check` can resolve them. On
// @astrojs/starlight upgrades, re-check those component module paths.
//
// The left nav and the controls/CTA are hidden below Starlight's `md` (50rem):
// under 50rem the sidebar (a hamburger drawer) already carries navigation on
// doc pages, leaving the logo + the (always-visible) search button.
import config from "virtual:starlight/user-config";

import LanguageSelect from "virtual:starlight/components/LanguageSelect";
import Search from "virtual:starlight/components/Search";
import SiteTitle from "virtual:starlight/components/SiteTitle";
import SocialIcons from "virtual:starlight/components/SocialIcons";
import ThemeSelect from "virtual:starlight/components/ThemeSelect";
import { headerNav } from "../config/sidebar.ts";

// Mirror of the package default: only render Search when Pagefind is enabled or
// the Search component has been overridden.
const shouldRenderSearch =
config.pagefind ||
config.components.Search !== "@astrojs/starlight/components/Search.astro";

// Highlight the active destination — exact match, or any nested page beneath it
// (so a "/guides" link stays current on "/guides/foo"). trailingSlash is
// "never", so strip a stray trailing slash before comparing, just in case.
const currentPath = Astro.url.pathname.replace(/(.)\/$/, "$1");
---

<div class="header">
<div class="wh-lead">
<div class="title-wrapper sl-flex">
<SiteTitle />
</div>
<nav
class="wh-header-nav sl-hidden md:sl-flex print:hidden"
aria-label="Primary"
>
{
headerNav.map(({ label, href }) => (
<a
href={href}
class="wh-header-nav__link"
aria-current={
currentPath === href || currentPath.startsWith(`${href}/`)
? "page"
: undefined
}
>
{label}
</a>
))
}
</nav>
</div>

<div class="wh-right">
{
shouldRenderSearch && (
<div class="wh-search sl-flex print:hidden">
<Search />
</div>
)
}
<div class="wh-controls sl-hidden md:sl-flex print:hidden">
<div class="sl-flex social-icons">
<SocialIcons />
</div>
<ThemeSelect />
<LanguageSelect />
</div>
</div>
</div>

<style>
/* Our own header layout. Unlayered so it authoritatively styles this
component's inner .header row (Starlight's own rules are @layer'd and would
otherwise win). Base case — doc pages — is full-bleed to the bar edges, like
Supabase/ClickHouse docs; the splash override below holds the inner content
in a centered content-width column instead. The outer PageFrame bar always
spans full width; only this inner content is ever capped + centered. */
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
height: 100%;
}
/* Splash homepage (the only page with a hero): hold the header in a centered
content-width column instead. Keyed off the hero's presence in the body —
the same signal global.css uses — rather than an SSR class/attribute on the
header, because vtbot's <ReplacementSwap/> PRESERVES the header element
across view-transitions while swapping the body content: a baked-in
attribute would go stale, but this `:has()` selector re-evaluates live. */
:global(body:has(.wh-hero)) .header {
max-width: 68rem;
margin-inline: auto;
}

.wh-lead {
display: flex;
align-items: center;
gap: 1.75rem;
min-width: 0;
}
.title-wrapper {
/* Avoid clipping the focus ring around the title link. */
padding: 0.25rem;
margin: -0.25rem;
min-width: 0;
}

.wh-right {
display: flex;
align-items: center;
gap: 1rem;
}
/* No `display` here on purpose — let the `sl-hidden md:sl-flex` utilities
govern visibility so the controls collapse under 50rem (an unlayered
`display: flex` would override Starlight's layered `sl-hidden`). This only
styles the flex row they become at >=50rem. */
.wh-controls {
align-items: center;
gap: 1rem;
}
.social-icons {
align-items: center;
gap: 1rem;
}
/* Hairline divider before the theme/lang controls, matching the default. */
.social-icons::after {
content: "";
height: 1.5rem;
border-inline-end: 1px solid var(--sl-color-gray-5);
}

/* Search. Starlight's expanded "Search … ⌘K" box on every page (≥50rem) so the
keyboard shortcut stays discoverable; below 50rem it sizes down to its icon. */
.wh-search {
align-items: center;
}
/* Expand to the box only from 50rem up — the same breakpoint that reveals the
controls. Below it the label + ⌘K are already hidden (md:-only), so forcing
a width would leave a wide, near-empty button overflowing the narrow header
beside the logo; letting it size to its icon keeps it compact there.
min-width:0 + max-width let it shrink rather than overflow if the desktop
row gets tight. */
@media (min-width: 50rem) {
.wh-search {
min-width: 0;
}
.wh-search :global(button[data-open-modal]) {
width: 22rem;
max-width: 100%;
}
}

/* Primary nav links — present but subordinate to the wordmark, brand accent on
hover and when current. */
.wh-header-nav {
align-items: center;
gap: 1.5rem;
}
.wh-header-nav__link {
/* A step up from muted secondary text so it reads as an interactive nav link
at rest (plain muted looked like a static label), but held just under the
wordmark's full ink so the brand stays dominant. */
color: color-mix(in oklab, var(--wh-ink) 80%, var(--wh-ink-muted));
/* Smaller than the "WaveHouse" wordmark (1.0625rem). Flex-centering leaves
the link's baseline ~1.25px ABOVE the wordmark's, so nudge it down to sit
their bottoms flush. Value gauged against the rendered wordmark baseline
(canvas font metrics disagree with the browser's actual placement here). */
font-size: 0.875rem;
transform: translateY(1.25px);
font-weight: 500;
line-height: 1;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s ease;
}
/* Brand accent on hover, matching the footer links (a clearer "this is a link"
cue than the earlier brighten-to-ink, which is now near-invisible from the
lifted resting colour). */
.wh-header-nav__link:hover {
color: var(--wh-accent-hi);
}
.wh-header-nav__link[aria-current="page"] {
color: var(--wh-accent-text);
font-weight: 600;
}
</style>
10 changes: 10 additions & 0 deletions docs/src/config/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,13 @@ export const sidebar: StarlightUserConfig["sidebar"] = [
],
},
];

// Top-level link(s) for the site header (src/components/Header.astro), colocated
// with the sidebar so header and sidebar share one file as the single source of
// truth for navigation. A single "Docs" entry into the doc tree: the splash
// homepage hides the sidebar, so this is the way in from there; every doc page
// already carries the full sidebar. Root-absolute href matches the internal link
// convention used across the docs (index.mdx hero, LinkCards).
export const headerNav: { label: string; href: string }[] = [
{ label: "Docs", href: "/getting-started" },
];
24 changes: 22 additions & 2 deletions docs/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// Starlight exposes its built-in components to overrides through these virtual
// modules (Vite resolves them at build time), but only ships their TYPES in an
// internal declaration file that isn't visible to user code — so `astro check`
// can't find them. Declare the ones the Footer override re-renders. See
// node_modules/@astrojs/starlight/virtual-internal.d.ts for the canonical list.
// can't find them. Declare the ones the Footer and Header overrides re-render.
// See node_modules/@astrojs/starlight/virtual-internal.d.ts for the canonical list.
declare module "virtual:starlight/components/EditLink" {
const Component: import("astro").AstroComponentFactory;
export default Component;
Expand All @@ -17,3 +17,23 @@ declare module "virtual:starlight/components/Pagination" {
const Component: import("astro").AstroComponentFactory;
export default Component;
}
declare module "virtual:starlight/components/LanguageSelect" {
const Component: import("astro").AstroComponentFactory;
export default Component;
}
declare module "virtual:starlight/components/Search" {
const Component: import("astro").AstroComponentFactory;
export default Component;
}
declare module "virtual:starlight/components/SiteTitle" {
const Component: import("astro").AstroComponentFactory;
export default Component;
}
declare module "virtual:starlight/components/SocialIcons" {
const Component: import("astro").AstroComponentFactory;
export default Component;
}
declare module "virtual:starlight/components/ThemeSelect" {
const Component: import("astro").AstroComponentFactory;
export default Component;
}