Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- **"Was this page helpful?" feedback widget on every docs page** (`docs/src/components/PageFeedback.astro` (new), `docs/src/components/Footer.astro`): a thumbs-up / thumbs-down vote below the page content, captured to PostHog as `docs_feedback` with `{ helpful, page }`. It renders from `Footer.astro`'s sidebar branch — the same indirection the Cloud CTA uses — rather than a per-page import or frontmatter flag, so every content page gets it automatically, including ones not written yet; it sits *below* the Cloud CTA on the pages that carry one, and splash pages (the homepage and 404) take the other footer branch and never render it. One vote per page per visitor: the choice is remembered in `localStorage` keyed by pathname, and a revisit renders the thanks message instead of re-prompting (storage is a nicety, not the record — a browser with storage disabled still votes).
- **Settings-directory validation — `wavehouse validate [dir]`** (`internal/settings/` (new: `settings.go`, `validate.go`, `decode.go`, `finding.go`, + tests), `cmd/wavehouse/validate.go` (new, + tests), `cmd/wavehouse/main.go`): first piece of the file-based control plane (settings live in a directory of JSON documents — `roles.json`, `policies.json`, `pipes.json`, `config.json` — that a running instance will hot-reload; this change is validation-only — boot loading and reload wiring land separately). `settings.Validate(dir)` is the single gate every consumer of the directory runs: deliberately pure (no network, no ClickHouse — table/column existence stays with schema discovery, per Bring-Your-Own-Schema), and it collects **all** findings in one pass instead of failing on the first. Checks, layered: the directory holds exactly the four files (a missing file is an error — an empty document is `{}`, so absence always means deletion or a wrong path; any unexpected entry — file or directory — is an error so a typoed `polices.json` or a stray backup can't be silently ignored; dot-prefixed entries are the one carve-out, since erroring on vim swap files or the `..data` machinery Kubernetes ConfigMap mounts publish through would break hand editing and the cloud fan-out's mount pattern alike); strict JSON syntax (unknown fields rejected — the JSON form of the retired-config-key trap; empty/truncated files rejected, never read as an empty document; a leading UTF-8 byte order mark named as such instead of surfacing as a cryptic invalid-character error; a directory, unreadable file, or non-regular file (a FIFO would hang the read forever waiting for a writer; a stat gate rejects it — following symlinks, so Kubernetes ConfigMap mounts' symlink layout still passes) squatting on a settings filename named as the one real problem, not double-reported as "missing"; a top-level `null` rejected — the one well-formed document that decodes into a zero value without error, so it would silently read as "no settings"; trailing content rejected; duplicated object keys detected by a token-level pass, since `encoding/json` silently keeps the last copy); per-file shape rules (role names non-empty/unique, pipe names/SQL/param types, `config.json` bounds mirroring boot-config validation — its sections are the *tenant-owned* behavioral tunables (dedupe id_field/require_id plus per-table overrides under `dedupe.tables` — each entry overrides only the fields it names, resolving table → global → compiled default per field, so the effective id_field can never be empty — an explicit empty, whitespace-only, or whitespace-padded id_field is rejected at both levels, since an exact-match JSON key lookup would silently miss every row ([#222](https://github.com/Wave-RF/WaveHouse/issues/222)'s shape, unblocked by the file design since table names are runtime-resolved like policy grants); query default_max_rows, schema refresh_interval, CORS origins); platform-owned knobs like the SSE keepalives deliberately stay boot config); and cross-file referential integrity (every role a policy grant, `default_role`/`admin_role`, or pipe allowlist references must be declared in `roles.json`; an empty role string in a grant or allowlist is named as such — it matches no request and authorizes nobody). Warnings don't invalidate: a grant scoping the admin role (an unconditional bypass — dead config), `default_role` = admin, and a `default` on a required pipe parameter are flagged but legal. An empty `policies.json` means no policy — fail closed, matching deleted-policy semantics — and draws a warning naming the total lockout, so it announces itself at validation time instead of one 403 at a time. The CLI (`cmd/wavehouse/validate.go`, following the `health` subcommand pattern) takes the directory as an argument or from `WH_SETTINGS_DIR`, prints findings, and exits 0/1/2 (valid/invalid/usage) so CI and operators can gate config changes before they reach a running instance. The dispatch in `main.go` also grows `help` and `version` subcommands, and an unknown command is now a usage error instead of silently falling through and starting the server (`wavehouse validat` booting a listener is not a typo anyone wants); each subcommand parses its arguments with a stdlib `flag.FlagSet`, so `wavehouse <command> -h` prints command-specific help and a stray flag or argument is a usage error rather than being silently swallowed. `WH_SETTINGS_DIR` has a single authority: `config.EnvSettingsDir`, with a reflection test pinning the `settings.dir` struct tag to it. The directory's location joins boot config as `settings.dir` (`WH_SETTINGS_DIR`; `internal/config/config.go`, `config.yaml`, `docs/src/content/docs/configuration.mdx`) — boot-tier by necessity, since it's the pointer the reload machinery follows; no default, same silent-misconfiguration reasoning as `policy.file_path`.

- **Docs-site analytics for search, code copies, 404s, docs section, and live-demo connectivity** (`docs/src/components/DocsTracking.astro` (new), `docs/src/components/{PostHog,Footer,LiveDemo}.astro`): the site tracked its own CTAs but nothing a reader did on the way to one, so the questions that decide what to write next — what people search for and *don't* find, which snippets get copied, which dead links keep getting followed — had no data behind them. `docs_search` fires a second after the query settles rather than once per keystroke, carrying `query` and `result_count` read off Pagefind's own results message (the rendered list is capped at its page size, so counting the DOM would under-report); `result_count: 0` is the event worth having. `code_copied` (`page`, `language`) watches Expressive Code's copy buttons from the document rather than re-binding every code block on every navigation — the hero's install chip is not an EC block and keeps its own `hero_install_copied`. `docs_404` (`path`, `referrer`) turns broken inbound links into a list instead of a hunch. A `doc_section` property (the first path segment, `home` for `/`) puts every event in a docs area without each tracker carrying its own copy; it's stamped at capture time by a `before_send` hook in `posthog.init()` rather than `register()`, because a queued `register()` replays only after init has already captured the first hard-load `$pageview` — which would then carry the previous visit's persisted value — and `history_change` navigations update the URL before capture fires, so reading `location` in the hook is always current. `live_demo_connected` fires once per mount when the hero's SSE feed comes up rather than on its first row — named for what it measures (the demo backend answered), since a quiet minute on the repo is not a disengaged reader. The three site-wide trackers share one new `DocsTracking.astro` rendered from the footer (like `MermaidZoom` / `ScrollHints`) and delegate from `document`, since Pagefind, Expressive Code, and the 404 route all own their own markup — some of it created after page load.
Expand Down
9 changes: 9 additions & 0 deletions docs/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import CloudCta from "./CloudCta.astro";
import DocsTracking from "./DocsTracking.astro";
import Logo from "./Logo.astro";
import MermaidZoom from "./MermaidZoom.astro";
import PageFeedback from "./PageFeedback.astro";
import ReadingProgress from "./ReadingProgress.astro";
import ScrollHints from "./ScrollHints.astro";
import Trademarks from "./Trademarks.astro";
Expand All @@ -43,6 +44,13 @@ const cloudCtaProps = cloudCta === true ? {} : cloudCta;
// CTA converts, the architecture one doesn't" without any per-page wiring.
const cloudCtaPlacement = `docs-${entry.id || "index"}`;

// "Was this page helpful?" rides the footer for the same reason the Cloud CTA
// does: every content page gets it — including ones not written yet — with no
// frontmatter flag and no import. The sidebar branch below already excludes
// splash pages; 404 is one of those today, and this keeps it excluded if that
// ever changes.
const showFeedback = entry.id !== "404" && !entry.id.endsWith("/404");

// Everything the page turns into visible words, for the per-page trademark
// notices. pageText() picks the frontmatter fields that actually render (hero
// tagline, title, this page's Cloud CTA copy) and leaves out the ones that
Expand All @@ -57,6 +65,7 @@ const trademarkSource = pageText(entry.data, entry.body ?? "");
<footer class="wh-footer wh-footer--compact not-content">
<ReadingProgress />
{cloudCtaProps && <CloudCta placement={cloudCtaPlacement} {...cloudCtaProps} />}
{showFeedback && <PageFeedback />}
<div class="wh-footer__meta">
<EditLink />
<LastUpdated />
Expand Down
232 changes: 232 additions & 0 deletions docs/src/components/PageFeedback.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
---
// "Was this page helpful?" — a two-button thumbs vote at the end of every doc
// page, captured to PostHog as `docs_feedback`.
//
// Rendered from Footer.astro's sidebar (doc-page) branch — the same indirection
// the Cloud CTA uses — so it lands on every content page, including ones not
// written yet, with no per-page frontmatter, import, or opt-in, and below the
// Cloud CTA on pages that carry one. Splash pages (homepage, 404) take the
// other footer branch and never render it.
//
// One vote per page per build: the choice is remembered in localStorage keyed
// by pathname and build SHA. A new docs build clears stale votes so visitors
// can re-evaluate updated content. After voting the choice stays visible and
// changeable — clicking the other thumb fires a new capture with `changed:
// true`. Storage is a nicety, not the record — the capture is what counts.

import { execSync } from "node:child_process";

// Thumbs-down is thumbs-up rotated 180° (the two Lucide glyphs are exact point
// reflections of each other), so the path is defined once and the CSS turns it.
const THUMB_PATH =
"M7 10v12M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z";

// ponytail: build SHA at SSG time — ties feedback to a specific docs revision
// and expires stale votes when docs are rebuilt.
let buildSha = "";
try {
buildSha = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch {
// Non-git builds (CI tarball, etc.): empty SHA matches empty, so votes
// persist across builds — acceptable for the edge case.
}

const votes = [
{ vote: "up", label: "Yes, this page was helpful" },
{ vote: "down", label: "No, this page was not helpful" },
] as const;
---

<div class="wh-feedback" data-wh-feedback data-wh-build={buildSha}>
<p class="wh-feedback__question">Was this page helpful?</p>
<p class="wh-feedback__thanks" role="status" tabindex="-1">Thanks for the feedback!</p>
<div class="wh-feedback__actions">
{
votes.map(({ vote, label }) => (
<button
type="button"
class:list={["wh-feedback__btn", `wh-feedback__btn--${vote}`]}
aria-label={label}
data-wh-feedback-vote={vote}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
>
<svg class="wh-feedback__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d={THUMB_PATH}
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
))
}
</div>
</div>

<script is:inline>
// is:inline (not a bundled module) to match the codebase's DOM-script
// convention (see Footer.astro / MermaidZoom.astro) and stay out of
// `astro check`. With view transitions this re-executes per navigation, so
// the window flag guards against re-binding the delegated click listener;
// per-page work runs from the persistent `astro:page-load` handler.
(() => {
if (window.__whPageFeedbackInit) return;
window.__whPageFeedbackInit = true;

const storageKey = () => "wh-docs-feedback:" + location.pathname;

function readVote(build) {
try {
const raw = window.localStorage.getItem(storageKey());
if (!raw) return null;
// ponytail: migrate bare "up"/"down" from pre-build-SHA format
if (raw === "up" || raw === "down") {
window.localStorage.removeItem(storageKey());
return null;
}
const parsed = JSON.parse(raw);
if (parsed.build !== build) {
// Stale build — clear so the visitor can re-evaluate updated content.
window.localStorage.removeItem(storageKey());
return null;
}
return parsed.vote;
} catch {
return null;
}
}

function rememberVote(vote, build) {
try {
window.localStorage.setItem(storageKey(), JSON.stringify({ vote, build }));
} catch {}
}

// Restore the already-voted state. Runs per page, soft navigations included.
function render() {
const root = document.querySelector("[data-wh-feedback]");
if (!root) return;
const vote = readVote(root.dataset.whBuild || "");
if (vote) {
root.dataset.vote = vote;
} else {
delete root.dataset.vote;
}
}

document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest("[data-wh-feedback-vote]");
if (!button) return;
const root = button.closest("[data-wh-feedback]");
if (!root) return;

const vote = button.dataset.whFeedbackVote;
const build = root.dataset.whBuild || "";
const oldVote = root.dataset.vote;

// Same thumb again — toggle off (retract the vote).
if (vote === oldVote) {
if (typeof window.posthog?.capture === "function") {
window.posthog.capture("docs_feedback", {
helpful: null, page: location.pathname, build, retracted: true,
});
try { window.localStorage.removeItem(storageKey()); } catch {}
}
delete root.dataset.vote;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return;
}

const helpful = vote === "up";
const changed = !!oldVote; // re-vote, not first vote

if (typeof window.posthog?.capture === "function") {
const props = { helpful, page: location.pathname, build };
if (changed) props.changed = true;
window.posthog.capture("docs_feedback", props);
rememberVote(vote, build);
}
Comment thread
jfwoods marked this conversation as resolved.
Resolved
root.dataset.vote = vote;
// Only steal focus on the first vote — a change-vote doesn't need it.
if (!changed) root.querySelector(".wh-feedback__thanks")?.focus();
});

document.addEventListener("astro:page-load", render);
if (document.readyState !== "loading") render();
else document.addEventListener("DOMContentLoaded", render);
})();
</script>

<style>
/* Centered, compact row: question/thanks + two thumb buttons inline.
Less prominent than page content — secondary text color, smaller type. */
.wh-feedback {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}

.wh-feedback__question {
margin: 0;
font-size: 0.8125rem;
color: var(--wh-ink-muted);
}

.wh-feedback__actions {
display: flex;
gap: 0.375rem;
}
.wh-feedback__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--wh-border);
border-radius: 0.375rem;
background: transparent;
color: var(--wh-ink-subtle);
cursor: pointer;
transition:
background 0.12s ease,
border-color 0.12s ease,
color 0.12s ease;
}
.wh-feedback__btn:hover {
background: var(--wh-surface-elevated);
border-color: var(--wh-border-strong);
color: var(--wh-ink-muted);
}
/* Active (voted) thumb — accent border + fill so the visitor sees their choice. */
.wh-feedback[data-vote="up"] .wh-feedback__btn--up,
.wh-feedback[data-vote="down"] .wh-feedback__btn--down {
background: var(--wh-surface-elevated);
border-color: var(--wh-accent);
color: var(--wh-accent-text);
}
.wh-feedback__icon {
width: 1rem;
height: 1rem;
}
.wh-feedback__btn--down .wh-feedback__icon {
transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
.wh-feedback__btn {
transition: none;
}
}

.wh-feedback__thanks {
display: none;
margin: 0;
font-size: 0.8125rem;
color: var(--wh-ink-muted);
}
/* Swap question → thanks after voting; buttons stay visible for change-vote. */
.wh-feedback[data-vote] .wh-feedback__question { display: none; }
.wh-feedback[data-vote] .wh-feedback__thanks { display: block; }
</style>