Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"@human-kit/ui": "workspace:*",
"@lucide/svelte": "^0.574.0",
"@sveltejs/adapter-vercel": "^6.3.1",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"minisearch": "^7.2.0",
"posthog-js": "^1.434.12",
"svelte-render-scan": "^1.1.0",
"tailwind-variants": "^3.2.2",
"vite-plugin-devtools-json": "^1.0.0"
Expand Down
25 changes: 25 additions & 0 deletions docs/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Handle } from '@sveltejs/kit';
import { isTelemetryProxyPath, proxyTelemetry } from '$lib/docs/telemetry/proxy';

/**
* Answers the telemetry path before the router sees it.
*
* The path is not a route of this site: it is the front of a proxy, and
* `proxy.ts` says why the events do not go straight to the vendor. A request
* that is not for the proxy goes on to the router, untouched.
*/
export const handle: Handle = async ({ event, resolve }) => {
if (isTelemetryProxyPath(event.url.pathname)) {
const method = event.request.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
return proxyTelemetry({
url: event.url,
method,
headers: event.request.headers,
body: hasBody ? await event.request.arrayBuffer() : null,
clientAddress: event.getClientAddress()
});
}

return resolve(event);
};
1 change: 1 addition & 0 deletions docs/src/lib/docs/components/code-copy/code-copy.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
props: {
text,
label: 'Copy code',
source: 'code-block',
class: 'absolute top-1.5 right-1.5'
}
});
Expand Down
18 changes: 17 additions & 1 deletion docs/src/lib/docs/components/copy-button/copy-button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,38 @@
import Button from '../button/button.svelte';
import Check from '../icons/check.svelte';
import Copy from '../icons/copy.svelte';
import { track } from '$lib/docs/telemetry';
import type { ButtonSize, ButtonVariant } from '../button/recipe';

// Reusable copy-to-clipboard button built on the docs Button component. Shows a
// check for a moment after copying (and disables itself so it can't re-fire).
//
// Every copy on the site goes through this button, so the `copy` event is sent
// from here: one place, and no call site can forget it.
interface Props {
text: string;
label?: string;
variant?: ButtonVariant;
size?: ButtonSize;
class?: string;
/**
* What the reader copied, for the telemetry: a code block of a guide, the
* source of a demo, an install command. A button with no source sends no
* event.
*/
source?: 'code-block' | 'demo-source' | 'install-command';
/** More about the copy, e.g. which package manager the command was for. */
detail?: string;
}

let {
text,
label = 'Copy',
variant = 'ghost',
size = 'icon-sm',
class: className = ''
class: className = '',
source,
detail
}: Props = $props();

let copied = $state(false);
Expand All @@ -30,6 +44,8 @@
copied = true;
clearTimeout(timeout);
timeout = setTimeout(() => (copied = false), 1500);
// After the write: a copy that the browser refused is not a copy.
if (source) track('copy', { source, detail: detail ?? null });
}
</script>

Expand Down
22 changes: 20 additions & 2 deletions docs/src/lib/docs/components/demo/demo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { buttonVariants } from '../button/recipe';
import Surface from '../surface/surface.svelte';
import Code from '../icons/code.svelte';
import { track } from '$lib/docs/telemetry';

interface Props {
source: { code: string; html: string };
Expand All @@ -14,18 +15,35 @@
let { source, children }: Props = $props();

let expanded = $state(false);

// One event for each demo, not one for each click: the question is whether the
// reader tried the component, and the answer does not become more true with
// the second press. A plain variable, because no markup reads it.
let interacted = false;

// In the capture phase: a primitive inside the demo can stop a press from
// going up, and the read of this signal must not depend on which one does.
function onPreviewInteraction() {
if (interacted) return;
interacted = true;
track('demo_interact');
}
</script>

<div class="not-prose my-4 overflow-hidden rounded-xl border border-border">
<!-- Preview -->
<div class="flex min-h-48 items-center justify-center bg-surface p-4 sm:p-8">
<div
class="flex min-h-48 items-center justify-center bg-surface p-4 sm:p-8"
onpointerdowncapture={onPreviewInteraction}
onkeydowncapture={onPreviewInteraction}
>
{@render children()}
</div>

<Collapsible.Root open={expanded} onOpenChange={(next) => (expanded = next)}>
<!-- Toolbar: a Surface so its buttons elevate relative to it (no hand-picked bg). -->
<Surface level={1} class="flex items-center justify-end gap-1 border-t p-1">
<CopyButton text={source.code} label="Copy source code" />
<CopyButton text={source.code} label="Copy source code" source="demo-source" />
<Collapsible.Trigger class={buttonVariants({ variant: 'ghost', size: 'sm' })}>
<Code />
{expanded ? 'Hide code' : 'Show code'}
Expand Down
2 changes: 2 additions & 0 deletions docs/src/lib/docs/components/header/header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { Frame } from '../frame/index.js';
import { buttonVariants } from '../button/recipe';
import Github from '../icons/github.svelte';
import { track } from '$lib/docs/telemetry';

interface Props {
title?: string;
Expand Down Expand Up @@ -109,6 +110,7 @@
rel="noreferrer"
aria-label="GitHub repository"
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
onclick={() => track('github_click')}
>
<Github />
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@
{/each}
</Tabs.List>

<CopyButton class="ml-auto" text={active.cmd} label="Copy {active.id} command" />
<CopyButton
class="ml-auto"
text={active.cmd}
label="Copy {active.id} command"
source="install-command"
detail={active.id}
/>
</div>

<!-- Only the command area is a <Surface>: the header keeps the ambient
Expand Down
27 changes: 27 additions & 0 deletions docs/src/lib/docs/components/search/search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { buttonVariants } from '../button/recipe';
import { track } from '$lib/docs/telemetry';
import {
groupHits,
hitLabel,
Expand Down Expand Up @@ -51,6 +52,32 @@
);
});

/** The shortest query worth a report. One letter matches too much to mean anything. */
const MIN_REPORTED_QUERY = 2;

/** How long the reader must stop writing before the query counts as a question. */
const REPORT_AFTER_MS = 700;

/** Longest query sent. A reader who pastes a page must not put it in the numbers. */
const MAX_QUERY_LENGTH = 80;

// What the readers look for, and what they do not find: a query with 0 results
// is the name of a component that is missing, or of a page that does not say
// the word the reader knows. The report waits for the writing to stop, so one
// question is one event and not one for each keystroke.
$effect(() => {
const text = query.trim();
const found = results.length;
// The index arrives after the dialog opens, and a 0 from before it is here
// means "not loaded", not "nothing found".
if (index === null || text.length < MIN_REPORTED_QUERY) return;
const timer = setTimeout(
() => track('search', { query: text.slice(0, MAX_QUERY_LENGTH), results: found }),
REPORT_AFTER_MS
);
return () => clearTimeout(timer);
});

// After mount rather than during render: the server has no platform to read,
// and the label has to change once the client knows which key to name.
onMount(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
import Button from '../button/button.svelte';
import Sun from '../icons/sun.svelte';
import Moon from '../icons/moon.svelte';
import { track } from '$lib/docs/telemetry';

function toggle() {
theme.toggle();
track('theme_change', { to: theme.dark ? 'dark' : 'light' });
}
</script>

<Button variant="ghost" size="icon" onclick={() => theme.toggle()} aria-label="Toggle color theme">
<Button variant="ghost" size="icon" onclick={toggle} aria-label="Toggle color theme">
<!-- Which icon shows is driven by the `.dark` class (set by the anti-FOUC
script before paint), not by JS state, so it never flips on hydration. -->
<Sun class="hidden dark:block" />
Expand Down
67 changes: 67 additions & 0 deletions docs/src/lib/docs/telemetry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Telemetry

Two layers, because one script cannot answer both questions.

## Layer 1 — the counts

`src/routes/+layout.ts` starts the analytics and the speed report of the host.
They give the views of a page, where a reader came from, the country, the device,
and the speed of a page for real readers.

There is nothing to configure. The host turns the feature on for the project, and
the scripts come from this domain. A content blocker matches the host of a
request, so a script from this domain is not stopped, and the counts stay
correct. Neither script writes to the device of the reader.

## Layer 2 — the clicks

`telemetry.ts` loads the client of the analytics vendor after the page hydrates.
It records each click with the element behind it, and it gives the heat maps.
`proxy.ts` and `src/hooks.server.ts` put the requests on a path of this domain,
for the same reason as above.

The client writes nothing to the device (`persistence: 'memory'`), so the site
needs no consent dialog. The cost is that a reader who comes back tomorrow is a
new reader. Layer 1 carries the counts that need an identity.

### To turn it on

Give the build two variables. Both are public, and both are read at build time.

| Variable | Value |
| --------------------- | ------------------------------------------ |
| `VITE_POSTHOG_KEY` | The project key. It can only write events. |
| `VITE_POSTHOG_REGION` | `eu` or `us`. The default is `eu`. |

Set them in the project of the host, for Production and for Preview. To try the
site on a machine, put them in `docs/.env.local`.

**The region must be the region of the project.** A key of one region is unknown
in the other, and the vendor answers with an error that the browser does not
show. Telemetry then looks installed and collects nothing.

Without `VITE_POSTHOG_KEY` each function does nothing. Therefore a build with no
key, and each test, send no events. Development is also off: a local page must
not add noise to the numbers of the site.

## The events

| Event | Properties | The question it answers |
| --------------- | ------------------ | ---------------------------------------------------- |
| `$pageview` | `route` | Which page a reader opens. |
| `search` | `query`, `results` | What a reader looks for. `results: 0` is a gap. |
| `copy` | `source`, `detail` | Which snippet a reader takes away. |
| `demo_interact` | — | The reader tried the demo, and did not only read it. |
| `github_click` | — | How many readers go to the repository. |
| `theme_change` | `to` | How many readers use the dark theme. |

Each click also arrives by itself, with the element behind it, because the client
records them without instructions. The six events above are the ones that are
named by hand, because a name makes a chart possible.

`search` with `results: 0` is the most valuable of them: the query is the name of
a component that is missing, or of a page that does not use the word the reader
knows.

To add an event, put its name in `TelemetryEvent` and call `track`. A name that
is not in that type is a build error.
17 changes: 17 additions & 0 deletions docs/src/lib/docs/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export {
startTelemetry,
telemetryIsOn,
track,
trackPageView,
type TelemetryEvent,
type TelemetryProperties
} from './telemetry';
export {
TELEMETRY_PROXY_PREFIX,
isTelemetryProxyPath,
proxyTelemetry,
telemetryTarget,
telemetryRequestHeaders,
telemetryResponseHeaders,
type TelemetryProxyRequest
} from './proxy';
Loading
Loading